diff --git a/CLAUDE.md b/CLAUDE.md index 5ba4c50e..6803c2de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -320,3 +320,50 @@ Shared libraries: - You should always write your code in a way that makes it easy to unit test. - Comments shouldn't be used unless absolutely necessary. Write readable code that can be understood without comments, and only include comments for unavoidable business logic. - Variable values should be separated into constants whenever possible. Avoid creating magic numbers. + +### NestJS GraphQL Resolver Method Ordering + +In NestJS GraphQL resolvers, organize methods by decorator type (not alphabetically across all methods). Within each decorator group, sort methods alphabetically. + +**Order**: + +1. `constructor` +2. `@Query` methods (alphabetically) +3. `@Mutation` methods (alphabetically) +4. `@ResolveField` methods (alphabetically) + +**Rationale**: GraphQL queries, mutations, and field resolvers are fundamentally different operations. Grouping by decorator type maintains logical cohesion while the decorator itself provides visual separation (no comments needed). + +**Example**: + +```typescript +@Resolver(() => Content) +export class ContentResolver { + constructor(private contentService: ContentService) {} + + @Query(() => Content) + async content(@Args("id") id: number) { + return await this.contentService.findContentById(id); + } + + @Query(() => [Content]) + async contentList(@Args("filter") filter?: ContentListFilter) { + return await this.contentService.findContentList(filter); + } + + @Mutation(() => ContentCreateResult) + async contentCreate(@Args("input") input: ContentCreateInput) { + return await this.contentService.createContent(input); + } + + @ResolveField(() => ContentCategory) + async contentCategory(@Parent() content: Content) { + return await this.dataLoaderService.contentCategory.findById(content.contentCategoryId); + } + + @ResolveField(() => String) + async displayName(@Parent() content: Content) { + return `${content.name}${content.gate ? ` ${content.gate}관문` : ""}`; + } +} +``` diff --git a/src/backend/eslint.config.js b/src/backend/eslint.config.js index 13aa88c6..67854b3a 100644 --- a/src/backend/eslint.config.js +++ b/src/backend/eslint.config.js @@ -7,7 +7,7 @@ export default [ ...tseslint.configs.recommended, { files: ["src/**/*.ts", "test/**/*.ts"], - ignores: ["dist", "node_modules"], + ignores: ["dist", "node_modules", "**/*.resolver.ts"], languageOptions: { parser: tseslint.parser, parserOptions: { @@ -66,4 +66,91 @@ export default [ ], }, }, + { + files: ["**/*.resolver.ts"], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaVersion: "latest", + project: "./tsconfig.json", + sourceType: "module", + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { + "@typescript-eslint": tseslint.plugin, + perfectionist, + }, + rules: { + "@typescript-eslint/consistent-type-definitions": ["error", "type"], + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + "perfectionist/sort-classes": [ + "error", + { + type: "alphabetical", + order: "asc", + groups: [ + "constructor", + "decorated-query-method", + "decorated-mutation-method", + "decorated-resolve-field-method", + "unknown", + ], + customGroups: [ + { + groupName: "decorated-query-method", + selector: "method", + modifiers: ["decorated"], + decoratorNamePattern: "Query", + }, + { + groupName: "decorated-mutation-method", + selector: "method", + modifiers: ["decorated"], + decoratorNamePattern: "Mutation", + }, + { + groupName: "decorated-resolve-field-method", + selector: "method", + modifiers: ["decorated"], + decoratorNamePattern: "ResolveField", + }, + ], + }, + ], + "perfectionist/sort-interfaces": [ + "error", + { + order: "asc", + type: "alphabetical", + }, + ], + "perfectionist/sort-object-types": [ + "error", + { + order: "asc", + type: "alphabetical", + }, + ], + "perfectionist/sort-objects": [ + "error", + { + order: "asc", + partitionByComment: true, + type: "alphabetical", + }, + ], + }, + }, ]; diff --git a/src/backend/package.json b/src/backend/package.json index d59aed26..e575b43b 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -34,12 +34,14 @@ "@prisma/client": "5.22.0", "@quixo3/prisma-session-store": "3.1.13", "apollo-server-express": "3.13.0", + "class-transformer": "0.5.1", + "class-validator": "0.14.2", "dataloader": "2.2.3", "dayjs": "1.11.13", "discord-webhook-node": "1.1.8", + "es-toolkit": "1.42.0", "express-session": "1.18.1", "graphql": "16.9.0", - "lodash": "4.17.21", "nestjs-cls": "6.1.0", "passport": "0.7.0", "passport-discord": "0.1.4", @@ -59,7 +61,6 @@ "@types/express": "5.0.0", "@types/express-session": "1.18.1", "@types/jest": "29.5.2", - "@types/lodash": "4.17.13", "@types/node": "20.3.1", "@types/passport": "1.0.17", "@types/passport-discord": "0.1.14", diff --git a/src/backend/pnpm-lock.yaml b/src/backend/pnpm-lock.yaml index 6c4b127e..b447c3f6 100644 --- a/src/backend/pnpm-lock.yaml +++ b/src/backend/pnpm-lock.yaml @@ -12,28 +12,28 @@ importers: version: 4.11.2(graphql@16.9.0) "@nestjs/apollo": specifier: 12.2.1 - version: 12.2.1(@apollo/server@4.11.2(graphql@16.9.0))(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(@nestjs/graphql@12.2.1(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(graphql@16.9.0)(reflect-metadata@0.2.0))(graphql@16.9.0) + version: 12.2.1(@apollo/server@4.11.2(graphql@16.9.0))(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(@nestjs/graphql@12.2.1(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(class-transformer@0.5.1)(class-validator@0.14.2)(graphql@16.9.0)(reflect-metadata@0.2.0))(graphql@16.9.0) "@nestjs/common": specifier: 10.4.16 - version: 10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1) + version: 10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1) "@nestjs/config": specifier: 3.3.0 - version: 3.3.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(rxjs@7.8.1) + version: 3.3.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(rxjs@7.8.1) "@nestjs/core": specifier: 10.0.0 - version: 10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) + version: 10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) "@nestjs/graphql": specifier: 12.2.1 - version: 12.2.1(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(graphql@16.9.0)(reflect-metadata@0.2.0) + version: 12.2.1(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(class-transformer@0.5.1)(class-validator@0.14.2)(graphql@16.9.0)(reflect-metadata@0.2.0) "@nestjs/passport": specifier: 10.0.3 - version: 10.0.3(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(passport@0.7.0) + version: 10.0.3(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(passport@0.7.0) "@nestjs/platform-express": specifier: 10.0.0 - version: 10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0) + version: 10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0) "@nestjs/serve-static": specifier: 4.0.2 - version: 4.0.2(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(express@4.21.2) + version: 4.0.2(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(express@4.21.2) "@prisma/client": specifier: 5.22.0 version: 5.22.0(prisma@6.4.1(typescript@5.1.3)) @@ -43,6 +43,12 @@ importers: apollo-server-express: specifier: 3.13.0 version: 3.13.0(express@4.21.2)(graphql@16.9.0) + class-transformer: + specifier: 0.5.1 + version: 0.5.1 + class-validator: + specifier: 0.14.2 + version: 0.14.2 dataloader: specifier: 2.2.3 version: 2.2.3 @@ -52,18 +58,18 @@ importers: discord-webhook-node: specifier: 1.1.8 version: 1.1.8 + es-toolkit: + specifier: 1.42.0 + version: 1.42.0 express-session: specifier: 1.18.1 version: 1.18.1 graphql: specifier: 16.9.0 version: 16.9.0 - lodash: - specifier: 4.17.21 - version: 4.17.21 nestjs-cls: specifier: 6.1.0 - version: 6.1.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) + version: 6.1.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) passport: specifier: 0.7.0 version: 0.7.0 @@ -103,7 +109,7 @@ importers: version: 10.0.0(chokidar@3.5.3)(typescript@5.1.3) "@nestjs/testing": specifier: 10.0.0 - version: 10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(@nestjs/platform-express@10.0.0) + version: 10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(@nestjs/platform-express@10.0.0) "@types/express": specifier: 5.0.0 version: 5.0.0 @@ -113,9 +119,6 @@ importers: "@types/jest": specifier: 29.5.2 version: 29.5.2 - "@types/lodash": - specifier: 4.17.13 - version: 4.17.13 "@types/node": specifier: 20.3.1 version: 20.3.1 @@ -148,7 +151,7 @@ importers: version: 29.5.0(@types/node@20.3.1)(ts-node@10.9.1(@types/node@20.3.1)(typescript@5.1.3)) nestjs-console: specifier: 9.0.0 - version: 9.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0) + version: 9.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0) source-map-support: specifier: 0.5.21 version: 0.5.21 @@ -1883,12 +1886,6 @@ packages: integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==, } - "@types/lodash@4.17.13": - resolution: - { - integrity: sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==, - } - "@types/long@4.0.2": resolution: { @@ -2021,6 +2018,12 @@ packages: integrity: sha512-j3/Z2avY+H3yn+xp/ef//QyqqE+dg3rWh14Ewi/QZs6uVK+oOs7lFRXtjp2YHAqHJZ4OFGNmCxZO5vd7AuG/Dg==, } + "@types/validator@13.15.10": + resolution: + { + integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==, + } + "@types/yargs-parser@21.0.3": resolution: { @@ -2823,6 +2826,18 @@ packages: integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==, } + class-transformer@0.5.1: + resolution: + { + integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==, + } + + class-validator@0.14.2: + resolution: + { + integrity: sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==, + } + cli-cursor@3.1.0: resolution: { @@ -3315,6 +3330,12 @@ packages: } engines: { node: ">= 0.4" } + es-toolkit@1.42.0: + resolution: + { + integrity: sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==, + } + esbuild-register@3.6.0: resolution: { @@ -4518,6 +4539,12 @@ packages: } engines: { node: ">= 0.8.0" } + libphonenumber-js@1.12.29: + resolution: + { + integrity: sha512-P2aLrbeqHbmh8+9P35LXQfXOKc7XJ0ymUKl7tyeyQjdRNfzunXWxQXGc4yl3fUf28fqLRfPY+vIVvFXK7KEBTw==, + } + lines-and-columns@1.2.4: resolution: { @@ -6226,6 +6253,13 @@ packages: } engines: { node: ">=10.12.0" } + validator@13.15.23: + resolution: + { + integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==, + } + engines: { node: ">= 0.10" } + value-or-promise@1.0.11: resolution: { @@ -7285,13 +7319,13 @@ snapshots: "@lukeed/csprng@1.1.0": {} - "@nestjs/apollo@12.2.1(@apollo/server@4.11.2(graphql@16.9.0))(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(@nestjs/graphql@12.2.1(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(graphql@16.9.0)(reflect-metadata@0.2.0))(graphql@16.9.0)": + "@nestjs/apollo@12.2.1(@apollo/server@4.11.2(graphql@16.9.0))(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(@nestjs/graphql@12.2.1(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(class-transformer@0.5.1)(class-validator@0.14.2)(graphql@16.9.0)(reflect-metadata@0.2.0))(graphql@16.9.0)": dependencies: "@apollo/server": 4.11.2(graphql@16.9.0) "@apollo/server-plugin-landing-page-graphql-playground": 4.0.0(@apollo/server@4.11.2(graphql@16.9.0)) - "@nestjs/common": 10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1) - "@nestjs/core": 10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) - "@nestjs/graphql": 12.2.1(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(graphql@16.9.0)(reflect-metadata@0.2.0) + "@nestjs/common": 10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/core": 10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/graphql": 12.2.1(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(class-transformer@0.5.1)(class-validator@0.14.2)(graphql@16.9.0)(reflect-metadata@0.2.0) graphql: 16.9.0 iterall: 1.3.0 lodash.omit: 4.5.0 @@ -7326,25 +7360,28 @@ snapshots: - uglify-js - webpack-cli - "@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1)": + "@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1)": dependencies: iterare: 1.2.1 reflect-metadata: 0.2.0 rxjs: 7.8.1 tslib: 2.8.1 uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.2 - "@nestjs/config@3.3.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(rxjs@7.8.1)": + "@nestjs/config@3.3.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(rxjs@7.8.1)": dependencies: - "@nestjs/common": 10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/common": 10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1) dotenv: 16.4.5 dotenv-expand: 10.0.0 lodash: 4.17.21 rxjs: 7.8.1 - "@nestjs/core@10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1)": + "@nestjs/core@10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1)": dependencies: - "@nestjs/common": 10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/common": 10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1) "@nuxtjs/opencollective": 0.3.2 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -7354,18 +7391,18 @@ snapshots: tslib: 2.5.3 uid: 2.0.2 optionalDependencies: - "@nestjs/platform-express": 10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0) + "@nestjs/platform-express": 10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0) transitivePeerDependencies: - encoding - "@nestjs/graphql@12.2.1(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(graphql@16.9.0)(reflect-metadata@0.2.0)": + "@nestjs/graphql@12.2.1(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(class-transformer@0.5.1)(class-validator@0.14.2)(graphql@16.9.0)(reflect-metadata@0.2.0)": dependencies: "@graphql-tools/merge": 9.0.8(graphql@16.9.0) "@graphql-tools/schema": 10.0.7(graphql@16.9.0) "@graphql-tools/utils": 10.5.5(graphql@16.9.0) - "@nestjs/common": 10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1) - "@nestjs/core": 10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) - "@nestjs/mapped-types": 2.0.5(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(reflect-metadata@0.2.0) + "@nestjs/common": 10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/core": 10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/mapped-types": 2.0.5(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0) chokidar: 4.0.1 fast-glob: 3.3.2 graphql: 16.9.0 @@ -7378,24 +7415,30 @@ snapshots: tslib: 2.8.0 uuid: 10.0.0 ws: 8.18.0 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.2 transitivePeerDependencies: - bufferutil - utf-8-validate - "@nestjs/mapped-types@2.0.5(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(reflect-metadata@0.2.0)": + "@nestjs/mapped-types@2.0.5(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)": dependencies: - "@nestjs/common": 10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/common": 10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1) reflect-metadata: 0.2.0 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.2 - "@nestjs/passport@10.0.3(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(passport@0.7.0)": + "@nestjs/passport@10.0.3(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(passport@0.7.0)": dependencies: - "@nestjs/common": 10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/common": 10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1) passport: 0.7.0 - "@nestjs/platform-express@10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)": + "@nestjs/platform-express@10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)": dependencies: - "@nestjs/common": 10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1) - "@nestjs/core": 10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/common": 10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/core": 10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) body-parser: 1.20.2 cors: 2.8.5 express: 4.18.2 @@ -7415,21 +7458,21 @@ snapshots: transitivePeerDependencies: - chokidar - "@nestjs/serve-static@4.0.2(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(express@4.21.2)": + "@nestjs/serve-static@4.0.2(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(express@4.21.2)": dependencies: - "@nestjs/common": 10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1) - "@nestjs/core": 10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/common": 10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/core": 10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) path-to-regexp: 0.2.5 optionalDependencies: express: 4.21.2 - "@nestjs/testing@10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(@nestjs/platform-express@10.0.0)": + "@nestjs/testing@10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(@nestjs/platform-express@10.0.0)": dependencies: - "@nestjs/common": 10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1) - "@nestjs/core": 10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/common": 10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/core": 10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) tslib: 2.5.3 optionalDependencies: - "@nestjs/platform-express": 10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0) + "@nestjs/platform-express": 10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0) "@noble/hashes@1.8.0": {} @@ -7656,8 +7699,6 @@ snapshots: "@types/json-schema@7.0.15": {} - "@types/lodash@4.17.13": {} - "@types/long@4.0.2": {} "@types/methods@1.1.4": {} @@ -7745,6 +7786,8 @@ snapshots: "@types/methods": 1.1.4 "@types/superagent": 8.1.9 + "@types/validator@13.15.10": {} + "@types/yargs-parser@21.0.3": {} "@types/yargs@17.0.35": @@ -8388,6 +8431,14 @@ snapshots: cjs-module-lexer@1.4.3: {} + class-transformer@0.5.1: {} + + class-validator@0.14.2: + dependencies: + "@types/validator": 13.15.10 + libphonenumber-js: 1.12.29 + validator: 13.15.23 + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -8623,6 +8674,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.42.0: {} + esbuild-register@3.6.0(esbuild@0.27.0): dependencies: debug: 4.4.3 @@ -9657,6 +9710,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.12.29: {} + lines-and-columns@1.2.4: {} loader-runner@4.3.1: {} @@ -9801,17 +9856,17 @@ snapshots: neo-async@2.6.2: {} - nestjs-cls@6.1.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1): + nestjs-cls@6.1.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1): dependencies: - "@nestjs/common": 10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1) - "@nestjs/core": 10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/common": 10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/core": 10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) reflect-metadata: 0.2.0 rxjs: 7.8.1 - nestjs-console@9.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0): + nestjs-console@9.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/core@10.0.0): dependencies: - "@nestjs/common": 10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1) - "@nestjs/core": 10.0.0(@nestjs/common@10.4.16(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/common": 10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1) + "@nestjs/core": 10.0.0(@nestjs/common@10.4.16(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.2.0)(rxjs@7.8.1) commander: 11.1.0 node-abort-controller@3.1.1: {} @@ -10592,6 +10647,8 @@ snapshots: "@types/istanbul-lib-coverage": 2.0.6 convert-source-map: 2.0.0 + validator@13.15.23: {} + value-or-promise@1.0.11: {} value-or-promise@1.0.12: {} diff --git a/src/backend/schema.graphql b/src/backend/schema.graphql index ce1a4743..27b89126 100644 --- a/src/backend/schema.graphql +++ b/src/backend/schema.graphql @@ -13,7 +13,10 @@ type AuctionItem { } input AuctionItemListFilter { + """가격 통계 수집 활성화 여부""" isStatScraperEnabled: Boolean + + """아이템 이름 검색 키워드""" nameKeyword: String } @@ -23,8 +26,43 @@ enum AuthProvider { KAKAO } +input CalculateCustomContentWageInput { + """아이템 목록""" + items: [CalculateCustomContentWageItemInput!]! + + """분""" + minutes: Int! + + """초""" + seconds: Int! +} + +input CalculateCustomContentWageItemInput { + """아이템 ID""" + id: Int! + + """획득 수량""" + quantity: Float! +} + +type CalculateCustomContentWageResult { + """회당 골드 획득량""" + goldAmountPerClear: Int! + + """시급 (골드)""" + goldAmountPerHour: Int! + + """시급 (원화)""" + krwAmountPerHour: Int! + + """성공 여부""" + ok: Boolean! +} + type Content { contentCategory: ContentCategory! + + """컨텐츠 카테고리 ID""" contentCategoryId: Int! contentRewards: [ContentReward!]! contentSeeMoreRewards: [ContentSeeMoreReward!]! @@ -32,12 +70,20 @@ type Content { displayName: String! duration: Int! durationText: String! + + """관문 번호""" gate: Int id: Int! + + """레벨""" level: Int! + + """컨텐츠 이름""" name: String! updatedAt: DateTime! wage(filter: ContentWageFilter): ContentWage! + + """시급 계산 필터""" wageFilter: ContentObjectWageFilter } @@ -49,31 +95,6 @@ type ContentCategory { updatedAt: DateTime! } -input ContentCreateInput { - categoryId: Int! - contentRewards: [ContentCreateItemsInput!]! - contentSeeMoreRewards: [ContentCreateSeeMoreRewardsInput!] - duration: Int! - gate: Int - level: Int! - name: String! -} - -input ContentCreateItemsInput { - averageQuantity: Float! - isBound: Boolean! - itemId: Int! -} - -type ContentCreateResult { - ok: Boolean! -} - -input ContentCreateSeeMoreRewardsInput { - itemId: Int! - quantity: Float! -} - type ContentDuration { content: Content! contentId: Int! @@ -83,30 +104,6 @@ type ContentDuration { value: Int! } -input ContentDurationEditInput { - contentId: Int! - minutes: Int! - seconds: Int! -} - -type ContentDurationEditResult { - ok: Boolean! -} - -input ContentDurationsEditInput { - contentDurations: [ContentDurationsEditInputDuration!]! -} - -input ContentDurationsEditInputDuration { - contentId: Int! - minutes: Int! - seconds: Int! -} - -type ContentDurationsEditResult { - ok: Boolean! -} - type ContentGroup { contentCategory: ContentCategory! contentCategoryId: Int! @@ -119,6 +116,7 @@ type ContentGroup { } input ContentGroupFilter { + """그룹화할 컨텐츠 ID 목록""" contentIds: [Int!] } @@ -130,25 +128,48 @@ type ContentGroupWage { } input ContentGroupWageListFilter { + """필터링할 컨텐츠 카테고리 ID""" contentCategoryId: Int - includeIsBound: Boolean - includeIsSeeMore: Boolean + + """귀속 아이템 포함 여부""" + includeBound: Boolean + + """포함할 아이템 ID 목록""" includeItemIds: [Int!] + + """추가 보상(더보기) 포함 여부""" + includeSeeMore: Boolean + + """검색 키워드""" keyword: String + + """컨텐츠 상태""" status: ContentStatus } input ContentListFilter { + """필터링할 컨텐츠 카테고리 ID""" contentCategoryId: Int - includeIsSeeMore: Boolean + + """추가 보상(더보기) 포함 여부""" + includeSeeMore: Boolean + + """검색 키워드""" keyword: String + + """컨텐츠 상태""" status: ContentStatus } type ContentObjectWageFilter { - includeIsBound: Boolean - includeIsSeeMore: Boolean + """귀속 아이템 포함 여부""" + includeBound: Boolean + + """포함할 아이템 ID 목록""" includeItemIds: [String!] + + """추가 보상(더보기) 포함 여부""" + includeSeeMore: Boolean } type ContentReward { @@ -161,35 +182,6 @@ type ContentReward { updatedAt: DateTime! } -input ContentRewardEditInput { - averageQuantity: Float! - contentId: Int! - isSellable: Boolean! - itemId: Int! -} - -input ContentRewardReportInput { - averageQuantity: Float! - id: Int! -} - -input ContentRewardsEditInput { - contentRewards: [ContentRewardEditInput!]! - isReportable: Boolean! -} - -type ContentRewardsEditResult { - ok: Boolean! -} - -input ContentRewardsReportInput { - contentRewards: [ContentRewardReportInput!]! -} - -type ContentRewardsReportResult { - ok: Boolean! -} - type ContentSeeMoreReward { contentId: Int! createdAt: DateTime! @@ -200,20 +192,6 @@ type ContentSeeMoreReward { updatedAt: DateTime! } -input ContentSeeMoreRewardEditInput { - contentId: Int! - itemId: Int! - quantity: Float! -} - -input ContentSeeMoreRewardsEditInput { - contentSeeMoreRewards: [ContentSeeMoreRewardEditInput!]! -} - -type ContentSeeMoreRewardsEditResult { - ok: Boolean! -} - enum ContentStatus { ACTIVE ARCHIVED @@ -228,63 +206,209 @@ type ContentWage { } input ContentWageFilter { - includeIsBound: Boolean - includeIsSeeMore: Boolean + """귀속 아이템 포함 여부""" + includeBound: Boolean + + """포함할 아이템 ID 목록""" includeItemIds: [Int!] + + """추가 보상(더보기) 포함 여부""" + includeSeeMore: Boolean } input ContentWageListFilter { + """필터링할 컨텐츠 카테고리 ID""" contentCategoryId: Int - includeIsBound: Boolean - includeIsSeeMore: Boolean + + """귀속 아이템 포함 여부""" + includeBound: Boolean + + """포함할 아이템 ID 목록""" includeItemIds: [Int!] + + """추가 보상(더보기) 포함 여부""" + includeSeeMore: Boolean + + """검색 키워드""" keyword: String + + """컨텐츠 상태""" status: ContentStatus } input ContentsFilter { + """필터링할 컨텐츠 ID 목록""" ids: [Int!] } -input CustomContentWageCalculateInput { - items: [CustomContentWageCalculateItemsInput!]! - minutes: Int! - seconds: Int! +input CreateContentInput { + """컨텐츠 카테고리 ID""" + categoryId: Int! + + """컨텐츠 보상 아이템 목록""" + contentRewards: [CreateContentItemInput!]! + + """추가 보상(더보기) 아이템 목록""" + contentSeeMoreRewards: [CreateContentSeeMoreRewardInput!] + + """소요 시간 (초)""" + duration: Int! + + """관문 번호""" + gate: Int + + """레벨""" + level: Int! + + """컨텐츠 이름""" + name: String! } -input CustomContentWageCalculateItemsInput { - id: Int! - quantity: Float! +input CreateContentItemInput { + """평균 획득 수량""" + averageQuantity: Float! + + """귀속 여부""" + isBound: Boolean! + + """아이템 ID""" + itemId: Int! } -type CustomContentWageCalculateResult { - goldAmountPerClear: Int! - goldAmountPerHour: Int! - krwAmountPerHour: Int! +type CreateContentResult { + """성공 여부""" ok: Boolean! } +input CreateContentSeeMoreRewardInput { + """아이템 ID""" + itemId: Int! + + """획득 수량""" + quantity: Float! +} + """ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. """ scalar DateTime -type GoldExchangeRate { - createdAt: DateTime! - goldAmount: Float! - id: Int! - krwAmount: Float! - updatedAt: DateTime! +input EditContentDurationInput { + """컨텐츠 ID""" + contentId: Int! + + """분""" + minutes: Int! + + """초""" + seconds: Int! +} + +type EditContentDurationResult { + """성공 여부""" + ok: Boolean! +} + +input EditContentDurationsDurationInput { + """컨텐츠 ID""" + contentId: Int! + + """분""" + minutes: Int! + + """초""" + seconds: Int! +} + +input EditContentDurationsInput { + """수정할 컨텐츠 소요 시간 목록""" + contentDurations: [EditContentDurationsDurationInput!]! +} + +type EditContentDurationsResult { + """성공 여부""" + ok: Boolean! +} + +input EditContentRewardInput { + """평균 획득 수량""" + averageQuantity: Float! + + """컨텐츠 ID""" + contentId: Int! + + """거래 가능 여부""" + isSellable: Boolean! + + """아이템 ID""" + itemId: Int! } -input GoldExchangeRateEditInput { +input EditContentRewardsInput { + """수정할 컨텐츠 보상 목록""" + contentRewards: [EditContentRewardInput!]! + + """제보 가능 여부""" + isReportable: Boolean! +} + +type EditContentRewardsResult { + """성공 여부""" + ok: Boolean! +} + +input EditContentSeeMoreRewardInput { + """컨텐츠 ID""" + contentId: Int! + + """아이템 ID""" + itemId: Int! + + """획득 수량""" + quantity: Float! +} + +input EditContentSeeMoreRewardsInput { + """수정할 추가 보상(더보기) 목록""" + contentSeeMoreRewards: [EditContentSeeMoreRewardInput!]! +} + +type EditContentSeeMoreRewardsResult { + """성공 여부""" + ok: Boolean! +} + +input EditGoldExchangeRateInput { + """100골드당 원화 금액""" krwAmount: Int! } -type GoldExchangeRateEditResult { +type EditGoldExchangeRateResult { + """성공 여부""" + ok: Boolean! +} + +input EditUserItemPriceInput { + """아이템 ID""" + id: Int! + + """사용자 지정 가격""" + price: Float! +} + +type EditUserItemPriceResult { + """성공 여부""" ok: Boolean! } +type GoldExchangeRate { + createdAt: DateTime! + goldAmount: Float! + id: Int! + krwAmount: Float! + updatedAt: DateTime! +} + type Item { createdAt: DateTime! id: Int! @@ -303,7 +427,10 @@ enum ItemKind { } input ItemsFilter { + """제외할 아이템 이름""" excludeItemName: String + + """아이템 종류""" kind: ItemKind } @@ -322,26 +449,36 @@ type MarketItem { } input MarketItemListFilter { + """카테고리 이름""" categoryName: String + + """등급""" grade: String + + """가격 통계 수집 활성화 여부""" isStatScraperEnabled: Boolean + + """검색 키워드""" keyword: String } type Mutation { - contentCreate(input: ContentCreateInput!): ContentCreateResult! - contentDurationEdit(input: ContentDurationEditInput!): ContentDurationEditResult! - contentDurationsEdit(input: ContentDurationsEditInput!): ContentDurationsEditResult! - contentRewardsEdit(input: ContentRewardsEditInput!): ContentRewardsEditResult! - contentRewardsReport(input: ContentRewardsReportInput!): ContentRewardsReportResult! - contentSeeMoreRewardsEdit(input: ContentSeeMoreRewardsEditInput!): ContentSeeMoreRewardsEditResult! - customContentWageCalculate(input: CustomContentWageCalculateInput!): CustomContentWageCalculateResult! - goldExchangeRateEdit(input: GoldExchangeRateEditInput!): GoldExchangeRateEditResult! - userItemPriceEdit(input: UserItemPriceEditInput!): UserItemPriceEditResult! + contentCreate(input: CreateContentInput!): CreateContentResult! + contentDurationEdit(input: EditContentDurationInput!): EditContentDurationResult! + contentDurationsEdit(input: EditContentDurationsInput!): EditContentDurationsResult! + contentRewardsEdit(input: EditContentRewardsInput!): EditContentRewardsResult! + contentRewardsReport(input: ReportContentRewardsInput!): ReportContentRewardsResult! + contentSeeMoreRewardsEdit(input: EditContentSeeMoreRewardsInput!): EditContentSeeMoreRewardsResult! + customContentWageCalculate(input: CalculateCustomContentWageInput!): CalculateCustomContentWageResult! + goldExchangeRateEdit(input: EditGoldExchangeRateInput!): EditGoldExchangeRateResult! + userItemPriceEdit(input: EditUserItemPriceInput!): EditUserItemPriceResult! } input OrderByArg { + """정렬 필드명""" field: String! + + """정렬 방향 (asc 또는 desc)""" order: String! } @@ -353,7 +490,7 @@ type Query { contentDuration(id: Int!): ContentDuration! contentGroup(filter: ContentGroupFilter): ContentGroup! contentGroupWageList(filter: ContentGroupWageListFilter, orderBy: [OrderByArg!]): [ContentGroupWage!]! - contentList(filter: ContentListFilter): [Content!]! + contentList(filter: ContentListFilter, orderBy: [OrderByArg!]): [Content!]! contentWageList(filter: ContentWageListFilter, orderBy: [OrderByArg!]): [ContentWage!]! contents(filter: ContentsFilter): [Content!]! goldExchangeRate: GoldExchangeRate! @@ -364,6 +501,24 @@ type Query { userList: [User!]! } +input ReportContentRewardInput { + """평균 획득 수량""" + averageQuantity: Float! + + """컨텐츠 보상 ID""" + id: Int! +} + +input ReportContentRewardsInput { + """제보할 컨텐츠 보상 목록""" + contentRewards: [ReportContentRewardInput!]! +} + +type ReportContentRewardsResult { + """성공 여부""" + ok: Boolean! +} + type User { createdAt: DateTime! displayName: String! @@ -385,15 +540,6 @@ type UserItem { userId: Int! } -input UserItemPriceEditInput { - id: Int! - price: Float! -} - -type UserItemPriceEditResult { - ok: Boolean! -} - enum UserRole { ADMIN OWNER diff --git a/src/backend/src/auth/auth.controller.ts b/src/backend/src/auth/auth.controller.ts index 1c6a8064..f76fdd31 100644 --- a/src/backend/src/auth/auth.controller.ts +++ b/src/backend/src/auth/auth.controller.ts @@ -1,4 +1,13 @@ -import { Controller, Get, Post, Req, Res, UseGuards } from "@nestjs/common"; +import { + Controller, + Get, + HttpException, + HttpStatus, + Post, + Req, + Res, + UseGuards, +} from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { AuthGuard } from "@nestjs/passport"; import { Request, Response } from "express"; @@ -61,11 +70,17 @@ export class AuthController { } @Post("logout") - async logout(@Req() req: Request) { - return req.logout((error) => { - if (error) { - console.error(error); - } + async logout(@Req() req: Request): Promise<{ + ok: boolean; + }> { + return new Promise((resolve, reject) => { + req.logout((error) => { + if (error) { + reject(new HttpException("Logout failed", HttpStatus.INTERNAL_SERVER_ERROR)); + } else { + resolve({ ok: true }); + } + }); }); } } diff --git a/src/backend/src/auth/auth.module.ts b/src/backend/src/auth/auth.module.ts index ca4d7c1e..4e195034 100644 --- a/src/backend/src/auth/auth.module.ts +++ b/src/backend/src/auth/auth.module.ts @@ -4,7 +4,7 @@ import { PassportModule } from "@nestjs/passport"; import { PrismaSessionStore } from "@quixo3/prisma-session-store"; import passport from "passport"; import session from "express-session"; -import { PrismaService } from "src/prisma"; +import { PrismaModule, PrismaService } from "src/prisma"; import { AuthController } from "./auth.controller"; import { GoogleStrategy } from "./strategy/google.strategy"; import { Serializer } from "./serializer"; @@ -15,7 +15,7 @@ import { KakaoStrategy } from "./strategy/kakao.strategy"; @Module({ controllers: [AuthController], - imports: [CommonModule, PassportModule.register({ session: true })], + imports: [CommonModule, PassportModule.register({ session: true }), PrismaModule], providers: [GoogleStrategy, DiscordStrategy, KakaoStrategy, Serializer, UserSeedService], }) export class AuthModule implements NestModule { diff --git a/src/backend/src/common/common.module.ts b/src/backend/src/common/common.module.ts index 2c47786f..d00dd9bc 100644 --- a/src/backend/src/common/common.module.ts +++ b/src/backend/src/common/common.module.ts @@ -1,11 +1,9 @@ import { Module } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; -import { PrismaService } from "src/prisma"; - @Module({ - exports: [ConfigModule, PrismaService], + exports: [ConfigModule], imports: [ConfigModule], - providers: [ConfigService, PrismaService], + providers: [ConfigService], }) export class CommonModule {} diff --git a/src/backend/src/common/constants/content.constants.ts b/src/backend/src/common/constants/content.constants.ts new file mode 100644 index 00000000..05e9bf95 --- /dev/null +++ b/src/backend/src/common/constants/content.constants.ts @@ -0,0 +1,2 @@ +export const CONTENT_LEVEL_MAX = 1700; +export const CONTENT_NAME_MAX_LENGTH = 100; diff --git a/src/backend/src/common/constants/item.constants.ts b/src/backend/src/common/constants/item.constants.ts new file mode 100644 index 00000000..8b179664 --- /dev/null +++ b/src/backend/src/common/constants/item.constants.ts @@ -0,0 +1,3 @@ +export const ITEM_CATEGORY_NAME_MAX_LENGTH = 100; +export const ITEM_GRADE_MAX_LENGTH = 50; +export const ITEM_KEYWORD_MAX_LENGTH = 100; diff --git a/src/backend/src/common/dto/mutation-result.dto.ts b/src/backend/src/common/dto/mutation-result.dto.ts new file mode 100644 index 00000000..ee54b49c --- /dev/null +++ b/src/backend/src/common/dto/mutation-result.dto.ts @@ -0,0 +1,7 @@ +import { Field, ObjectType } from "@nestjs/graphql"; + +@ObjectType({ isAbstract: true }) +export abstract class MutationResult { + @Field(() => Boolean, { description: "성공 여부" }) + ok: boolean; +} diff --git a/src/backend/src/common/object/order-by-arg.object.ts b/src/backend/src/common/object/order-by-arg.object.ts index ca3c49e7..1fadbd60 100644 --- a/src/backend/src/common/object/order-by-arg.object.ts +++ b/src/backend/src/common/object/order-by-arg.object.ts @@ -1,11 +1,15 @@ import { Field, InputType } from "@nestjs/graphql"; import { Prisma } from "@prisma/client"; +import { IsIn, IsString } from "class-validator"; @InputType() export class OrderByArg { - @Field() + @Field({ description: "정렬 필드명" }) + @IsString() field: string; - @Field(() => String) + @Field(() => String, { description: "정렬 방향 (asc 또는 desc)" }) + @IsString() + @IsIn(["asc", "desc"]) order: Prisma.SortOrder; } diff --git a/src/backend/src/common/pipes/validation.pipe.e2e-spec.ts b/src/backend/src/common/pipes/validation.pipe.e2e-spec.ts new file mode 100644 index 00000000..4c1e3d20 --- /dev/null +++ b/src/backend/src/common/pipes/validation.pipe.e2e-spec.ts @@ -0,0 +1,59 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import { GraphQLModule } from "@nestjs/graphql"; +import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo"; +import { Query, Resolver } from "@nestjs/graphql"; + +@Resolver() +class DummyResolver { + @Query(() => String) + dummyQuery(): string { + return "dummy"; + } +} + +describe("ValidationPipe Integration (e2e)", () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + GraphQLModule.forRoot({ + autoSchemaFile: true, + driver: ApolloDriver, + }), + ], + providers: [DummyResolver], + }).compile(); + + app = moduleFixture.createNestApplication(); + + // Apply same ValidationPipe config as main.ts + app.useGlobalPipes( + new ValidationPipe({ + skipNullProperties: true, + skipUndefinedProperties: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + whitelist: false, + }) + ); + + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("ValidationPipe가 GraphQL에서 작동하는지 확인", () => { + // This test verifies that ValidationPipe is properly configured + // and integrated with the GraphQL module + expect(app).toBeDefined(); + }); + + // Note: Full integration tests would require setting up actual resolvers + // and database connections. Those are covered in resolver e2e tests. +}); diff --git a/src/backend/src/console/services/console.service.ts b/src/backend/src/console/services/console.service.ts index 99be63fc..e92d4dbe 100644 --- a/src/backend/src/console/services/console.service.ts +++ b/src/backend/src/console/services/console.service.ts @@ -12,11 +12,8 @@ export class ConsoleService { description: "Seed 데이터 추가", }) async seed() { - try { - console.log("Seeding database..."); - await this.seedService.all(); - } catch (error) { - console.error(error); - } + console.log("Seeding database..."); + await this.seedService.all(); + console.log("Seeding completed successfully."); } } diff --git a/src/backend/src/content/object/content-category.object.ts b/src/backend/src/content/category/category.object.ts similarity index 100% rename from src/backend/src/content/object/content-category.object.ts rename to src/backend/src/content/category/category.object.ts diff --git a/src/backend/src/content/query/content-categories.query.ts b/src/backend/src/content/category/category.resolver.ts similarity index 72% rename from src/backend/src/content/query/content-categories.query.ts rename to src/backend/src/content/category/category.resolver.ts index 599ddda7..810d4a29 100644 --- a/src/backend/src/content/query/content-categories.query.ts +++ b/src/backend/src/content/category/category.resolver.ts @@ -1,9 +1,9 @@ import { Query, Resolver } from "@nestjs/graphql"; import { PrismaService } from "src/prisma"; -import { ContentCategory } from "../object/content-category.object"; +import { ContentCategory } from "./category.object"; @Resolver() -export class ContentCategoriesQuery { +export class CategoryResolver { constructor(private prisma: PrismaService) {} @Query(() => [ContentCategory]) diff --git a/src/backend/src/content/content.module.ts b/src/backend/src/content/content.module.ts index 0cc12b5a..b0f6d974 100644 --- a/src/backend/src/content/content.module.ts +++ b/src/backend/src/content/content.module.ts @@ -1,71 +1,47 @@ import { Module } from "@nestjs/common"; +import { DataLoaderService } from "src/dataloader/data-loader.service"; import { PrismaModule } from "src/prisma"; -import { ContentListQuery } from "./query/content-list.query"; -import { ContentResolver } from "./object/content.resolver"; -import { ItemsQuery } from "./query/items.query"; -import { ContentCategoriesQuery } from "./query/content-categories.query"; -import { ContentWageService } from "./service/content-wage.service"; -import { ContentQuery } from "./query/content.query"; -import { ContentRewardsEditMutation } from "./mutation/content-rewards-edit.mutation"; -import { ContentRewardResolver } from "./object/content-reward.resolver"; import { UserContentService } from "../user/service/user-content.service"; -import { ContentWageListQuery } from "./query/content-wage-list.query"; -import { ContentWageResolver } from "./object/content-wage.resolver"; -import { ContentSeeMoreRewardResolver } from "./object/content-see-more-reward.resolver"; -import { ContentDurationEditMutation } from "./mutation/content-duration-edit.mutation"; -import { ContentDurationResolver } from "./object/content-duration.resolver"; -import { ContentDurationQuery } from "./query/content-duration.query"; -import { UserGoldExchangeRateService } from "src/user/service/user-gold-exchange-rate.service"; -import { ItemResolver } from "./object/item.resolver"; -import { UserItemPriceEditMutation } from "./mutation/user-item-price-edit.mutation"; -import { ItemQuery } from "./query/item.query"; -import { CustomContentWageCalculateMutation } from "./mutation/custom-content-wage-calculate.mutation"; -import { ContentRewardsReportMutation } from "./mutation/content-rewards-report.mutation"; -import { ContentController } from "./content.controller"; -import { ContentCreateMutation } from "./mutation/content-create.mutation"; -import { DataLoaderService } from "src/dataloader/data-loader.service"; -import { ContentSeeMoreRewardsEditMutation } from "./mutation/content-see-more-rewards-edit.mutation"; -import { ContentDurationService } from "./service/content-duration.service"; -import { ContentGroupResolver } from "./object/content-group.resolver"; -import { ContentGroupWageListQuery } from "./query/content-group-wage-list.query"; -import { ContentDurationsEditMutation } from "./mutation/content-durations-edit.mutation"; -import { ContentsQuery } from "./query/contents.query"; -import { ContentGroupQuery } from "./query/content-group.query"; +import { UserGoldExchangeRateService } from "../user/service/user-gold-exchange-rate.service"; +import { CategoryResolver } from "./category/category.resolver"; +import { ContentResolver } from "./content/content.resolver"; +import { ContentService } from "./content/content.service"; +import { ContentDurationService } from "./duration/duration.service"; +import { DurationResolver } from "./duration/duration.resolver"; +import { GroupResolver } from "./group/group.resolver"; +import { GroupService } from "./group/group.service"; +import { ItemResolver } from "./item/item.resolver"; +import { ItemService } from "./item/item.service"; +import { RewardResolver } from "./reward/reward.resolver"; +import { RewardService } from "./reward/reward.service"; +import { SeeMoreRewardResolver } from "./see-more-reward/see-more-reward.resolver"; +import { SeeMoreRewardService } from "./see-more-reward/see-more-reward.service"; +import { ContentController } from "./shared/content.controller"; +import { ContentWageService } from "./wage/wage.service"; +import { WageResolver } from "./wage/wage.resolver"; @Module({ controllers: [ContentController], imports: [PrismaModule], providers: [ - ContentListQuery, - ItemsQuery, + CategoryResolver, + ContentDurationService, ContentResolver, - ContentCategoriesQuery, + ContentService, ContentWageService, - ContentQuery, - ContentRewardsEditMutation, - ContentRewardResolver, + DataLoaderService, + DurationResolver, + GroupResolver, + GroupService, + ItemResolver, + ItemService, + RewardResolver, + RewardService, + SeeMoreRewardResolver, + SeeMoreRewardService, UserContentService, - ContentWageListQuery, - ContentWageResolver, - ContentSeeMoreRewardResolver, - ContentDurationEditMutation, - ContentDurationResolver, - ContentDurationQuery, UserGoldExchangeRateService, - ItemResolver, - UserItemPriceEditMutation, - ItemQuery, - CustomContentWageCalculateMutation, - ContentRewardsReportMutation, - ContentCreateMutation, - DataLoaderService, - ContentSeeMoreRewardsEditMutation, - ContentDurationService, - ContentGroupWageListQuery, - ContentGroupResolver, - ContentDurationsEditMutation, - ContentsQuery, - ContentGroupQuery, + WageResolver, ], }) export class ContentModule {} diff --git a/src/backend/src/content/content/content.dto.spec.ts b/src/backend/src/content/content/content.dto.spec.ts new file mode 100644 index 00000000..4d8def87 --- /dev/null +++ b/src/backend/src/content/content/content.dto.spec.ts @@ -0,0 +1,249 @@ +import { validate } from "class-validator"; +import { plainToInstance } from "class-transformer"; +import { ContentListFilter, CreateContentInput, CreateContentItemInput } from "./content.dto"; +import { ContentStatus } from "@prisma/client"; + +describe("ContentListFilter Validation", () => { + it("유효한 필터 입력을 허용해야 함", async () => { + const filter = plainToInstance(ContentListFilter, { + contentCategoryId: 1, + includeSeeMore: true, + keyword: "test", + status: ContentStatus.ACTIVE, + }); + + const errors = await validate(filter); + expect(errors).toHaveLength(0); + }); + + it("모든 필드가 없어도 허용해야 함 (optional)", async () => { + const filter = plainToInstance(ContentListFilter, {}); + + const errors = await validate(filter); + expect(errors).toHaveLength(0); + }); + + it("contentCategoryId가 0이어도 허용해야 함", async () => { + const filter = plainToInstance(ContentListFilter, { + contentCategoryId: 0, + }); + + const errors = await validate(filter); + expect(errors).toHaveLength(0); + }); + + it("빈 keyword를 허용해야 함", async () => { + const filter = plainToInstance(ContentListFilter, { + keyword: "", + }); + + const errors = await validate(filter); + expect(errors).toHaveLength(0); + }); + + it("keyword가 100자를 초과하면 에러", async () => { + const filter = plainToInstance(ContentListFilter, { + keyword: "a".repeat(101), + }); + + const errors = await validate(filter); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("keyword"); + }); + + it("잘못된 status enum을 거부해야 함", async () => { + const filter = plainToInstance(ContentListFilter, { + status: "INVALID_STATUS", + }); + + const errors = await validate(filter); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("status"); + }); +}); + +describe("CreateContentInput Validation", () => { + const validInput = { + categoryId: 1, + contentRewards: [ + { + averageQuantity: 10.5, + isBound: false, + itemId: 1, + }, + ], + duration: 300, + level: 1600, + name: "Test Content", + }; + + it("유효한 입력을 허용해야 함", async () => { + const input = plainToInstance(CreateContentInput, validInput); + + const errors = await validate(input); + expect(errors).toHaveLength(0); + }); + + it("categoryId가 0 이하면 에러", async () => { + const input = plainToInstance(CreateContentInput, { + ...validInput, + categoryId: 0, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("categoryId"); + }); + + it("level이 1 미만이면 에러", async () => { + const input = plainToInstance(CreateContentInput, { + ...validInput, + level: 0, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("level"); + }); + + it("level이 1700 초과면 에러", async () => { + const input = plainToInstance(CreateContentInput, { + ...validInput, + level: 1701, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("level"); + }); + + it("level 1과 1700은 유효함 (경계값)", async () => { + const input1 = plainToInstance(CreateContentInput, { + ...validInput, + level: 1, + }); + const input2 = plainToInstance(CreateContentInput, { + ...validInput, + level: 1700, + }); + + const errors1 = await validate(input1); + const errors2 = await validate(input2); + + expect(errors1).toHaveLength(0); + expect(errors2).toHaveLength(0); + }); + + it("duration이 0 이하면 에러", async () => { + const input = plainToInstance(CreateContentInput, { + ...validInput, + duration: 0, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("duration"); + }); + + it("name이 비어있으면 에러", async () => { + const input = plainToInstance(CreateContentInput, { + ...validInput, + name: "", + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("name"); + }); + + it("name이 100자를 초과하면 에러", async () => { + const input = plainToInstance(CreateContentInput, { + ...validInput, + name: "a".repeat(101), + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("name"); + }); + + it("contentRewards가 빈 배열이면 에러", async () => { + const input = plainToInstance(CreateContentInput, { + ...validInput, + contentRewards: [], + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("contentRewards"); + }); + + it("gate가 0 이하면 에러", async () => { + const input = plainToInstance(CreateContentInput, { + ...validInput, + gate: 0, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("gate"); + }); + + it("gate가 null이면 허용", async () => { + const input = plainToInstance(CreateContentInput, { + ...validInput, + gate: null, + }); + + const errors = await validate(input); + expect(errors).toHaveLength(0); + }); +}); + +describe("CreateContentItemInput Validation", () => { + it("유효한 입력을 허용해야 함", async () => { + const input = plainToInstance(CreateContentItemInput, { + averageQuantity: 10.5, + isBound: false, + itemId: 1, + }); + + const errors = await validate(input); + expect(errors).toHaveLength(0); + }); + + it("averageQuantity가 음수면 에러", async () => { + const input = plainToInstance(CreateContentItemInput, { + averageQuantity: -1, + isBound: false, + itemId: 1, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("averageQuantity"); + }); + + it("averageQuantity가 0이면 허용", async () => { + const input = plainToInstance(CreateContentItemInput, { + averageQuantity: 0, + isBound: false, + itemId: 1, + }); + + const errors = await validate(input); + expect(errors).toHaveLength(0); + }); + + it("itemId가 0 이하면 에러", async () => { + const input = plainToInstance(CreateContentItemInput, { + averageQuantity: 10, + isBound: false, + itemId: 0, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("itemId"); + }); +}); diff --git a/src/backend/src/content/content/content.dto.ts b/src/backend/src/content/content/content.dto.ts new file mode 100644 index 00000000..ffc750cc --- /dev/null +++ b/src/backend/src/content/content/content.dto.ts @@ -0,0 +1,151 @@ +import { Field, Float, InputType, ObjectType } from "@nestjs/graphql"; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Max, + MaxLength, + Min, + ValidateNested, +} from "class-validator"; +import { ContentStatus } from "@prisma/client"; +import { Type } from "class-transformer"; +import { CONTENT_LEVEL_MAX, CONTENT_NAME_MAX_LENGTH } from "src/common/constants/content.constants"; +import { MutationResult } from "src/common/dto/mutation-result.dto"; + +@InputType() +export class ContentListFilter { + @Field({ + description: "필터링할 컨텐츠 카테고리 ID", + nullable: true, + }) + @IsOptional() + @IsNumber() + contentCategoryId?: number; + + @Field(() => Boolean, { + description: "추가 보상(더보기) 포함 여부", + nullable: true, + }) + @IsOptional() + @IsBoolean() + includeSeeMore?: boolean; + + @Field(() => String, { + description: "검색 키워드", + nullable: true, + }) + @IsOptional() + @IsString() + @MaxLength(CONTENT_NAME_MAX_LENGTH) + keyword?: string; + + @Field(() => ContentStatus, { + description: "컨텐츠 상태", + nullable: true, + }) + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; +} + +@InputType() +export class ContentsFilter { + @Field(() => [Number], { + description: "필터링할 컨텐츠 ID 목록", + nullable: true, + }) + @IsOptional() + @IsArray() + @IsNumber({}, { each: true }) + @Min(1, { each: true }) + ids?: number[]; +} + +@InputType() +export class CreateContentInput { + @Field({ description: "컨텐츠 카테고리 ID" }) + @IsNumber() + @Min(1) + categoryId: number; + + @Field(() => [CreateContentItemInput], { + description: "컨텐츠 보상 아이템 목록", + }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateContentItemInput) + contentRewards: CreateContentItemInput[]; + + @Field(() => [CreateContentSeeMoreRewardInput], { + description: "추가 보상(더보기) 아이템 목록", + nullable: true, + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateContentSeeMoreRewardInput) + contentSeeMoreRewards?: CreateContentSeeMoreRewardInput[]; + + @Field({ description: "소요 시간 (초)" }) + @IsNumber() + @Min(1) + duration: number; + + @Field({ description: "관문 번호", nullable: true }) + @IsOptional() + @IsNumber() + @Min(1) + gate?: number | null; + + @Field({ description: "레벨" }) + @IsNumber() + @Min(1) + @Max(CONTENT_LEVEL_MAX) + level: number; + + @Field({ description: "컨텐츠 이름" }) + @IsString() + @IsNotEmpty() + @MaxLength(CONTENT_NAME_MAX_LENGTH) + name: string; +} + +@InputType() +export class CreateContentItemInput { + @Field(() => Float, { description: "평균 획득 수량" }) + @IsNumber() + @Min(0) + averageQuantity: number; + + @Field({ description: "귀속 여부" }) + @IsBoolean() + isBound: boolean; + + @Field({ description: "아이템 ID" }) + @IsNumber() + @Min(1) + itemId: number; +} + +@InputType() +export class CreateContentSeeMoreRewardInput { + @Field({ description: "아이템 ID" }) + @IsNumber() + @Min(1) + itemId: number; + + @Field(() => Float, { description: "획득 수량" }) + @IsNumber() + @Min(0) + quantity: number; +} + +@ObjectType() +export class CreateContentResult extends MutationResult {} diff --git a/src/backend/src/content/content/content.object.ts b/src/backend/src/content/content/content.object.ts new file mode 100644 index 00000000..488b2aa6 --- /dev/null +++ b/src/backend/src/content/content/content.object.ts @@ -0,0 +1,44 @@ +import { Field, ObjectType } from "@nestjs/graphql"; +import { BaseObject } from "src/common/object/base.object"; + +@ObjectType() +export class ContentObjectWageFilter { + @Field(() => Boolean, { + description: "귀속 아이템 포함 여부", + nullable: true, + }) + includeBound?: boolean; + + @Field(() => [String], { + description: "포함할 아이템 ID 목록", + nullable: true, + }) + includeItemIds?: string[]; + + @Field(() => Boolean, { + description: "추가 보상(더보기) 포함 여부", + nullable: true, + }) + includeSeeMore?: boolean; +} + +@ObjectType() +export class Content extends BaseObject { + @Field({ description: "컨텐츠 카테고리 ID" }) + contentCategoryId: number; + + @Field({ description: "관문 번호", nullable: true }) + gate?: number; + + @Field({ description: "레벨" }) + level: number; + + @Field({ description: "컨텐츠 이름" }) + name: string; + + @Field(() => ContentObjectWageFilter, { + description: "시급 계산 필터", + nullable: true, + }) + wageFilter?: ContentObjectWageFilter; +} diff --git a/src/backend/src/content/query/content-list.query.e2e-spec.ts b/src/backend/src/content/content/content.resolver.e2e-spec.ts similarity index 100% rename from src/backend/src/content/query/content-list.query.e2e-spec.ts rename to src/backend/src/content/content/content.resolver.e2e-spec.ts diff --git a/src/backend/src/content/content/content.resolver.ts b/src/backend/src/content/content/content.resolver.ts new file mode 100644 index 00000000..0e58c421 --- /dev/null +++ b/src/backend/src/content/content/content.resolver.ts @@ -0,0 +1,102 @@ +import { UseGuards } from "@nestjs/common"; +import { Args, Mutation, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; +import { User } from "@prisma/client"; +import { AuthGuard } from "src/auth/auth.guard"; +import { CurrentUser } from "src/common/decorator/current-user.decorator"; +import { OrderByArg } from "src/common/object/order-by-arg.object"; +import { DataLoaderService } from "src/dataloader/data-loader.service"; +import { UserContentService } from "src/user/service/user-content.service"; +import { ContentCategory } from "../category/category.object"; +import { ContentReward } from "../reward/reward.object"; +import { ContentSeeMoreReward } from "../see-more-reward/see-more-reward.object"; +import { ContentWageService } from "../wage/wage.service"; +import { ContentWageFilter } from "../wage/wage.dto"; +import { ContentWage } from "../wage/wage.object"; +import { + ContentListFilter, + ContentsFilter, + CreateContentInput, + CreateContentResult, +} from "./content.dto"; +import { ContentService } from "./content.service"; +import { Content } from "./content.object"; + +@Resolver(() => Content) +export class ContentResolver { + constructor( + private contentService: ContentService, + private userContentService: UserContentService, + private dataLoaderService: DataLoaderService, + private contentWageService: ContentWageService + ) {} + + @Query(() => Content) + async content(@Args("id") id: number) { + return await this.contentService.findContentById(id); + } + + @Query(() => [Content]) + async contentList( + @Args("filter", { nullable: true }) filter?: ContentListFilter, + @Args("orderBy", { nullable: true, type: () => [OrderByArg] }) orderBy?: OrderByArg[] + ) { + return await this.contentService.findContentList(filter, orderBy); + } + + @Query(() => [Content]) + async contents(@Args("filter", { nullable: true }) filter?: ContentsFilter) { + return await this.contentService.findContents(filter); + } + + @UseGuards(AuthGuard) + @Mutation(() => CreateContentResult) + async contentCreate(@Args("input") input: CreateContentInput) { + return await this.contentService.createContent(input); + } + + @ResolveField(() => ContentCategory) + async contentCategory(@Parent() content: Content) { + return await this.dataLoaderService.contentCategory.findUniqueOrThrowById( + content.contentCategoryId + ); + } + + @ResolveField(() => [ContentReward]) + async contentRewards(@Parent() content: Content) { + return await this.dataLoaderService.contentRewards.findManyByContentId(content.id); + } + + @ResolveField(() => [ContentSeeMoreReward]) + async contentSeeMoreRewards(@Parent() content: Content) { + return await this.dataLoaderService.contentSeeMoreRewards.findManyByContentId(content.id); + } + + @ResolveField(() => String) + async displayName(@Parent() content: Content) { + const { gate, name } = content; + return `${name}${gate ? ` ${gate}관문` : ""}`; + } + + @ResolveField(() => Number) + async duration(@Parent() content: Content, @CurrentUser() user?: User) { + return await this.userContentService.getContentDuration(content.id, user?.id); + } + + @ResolveField(() => String) + async durationText(@Parent() content: Content) { + const durationInSeconds = await this.duration(content); + const minutes = Math.floor(durationInSeconds / 60); + const seconds = durationInSeconds % 60; + + return seconds === 0 ? `${minutes}분` : `${minutes}분 ${seconds}초`; + } + + @ResolveField(() => ContentWage) + async wage( + @Parent() content: Content, + @Args("filter", { nullable: true }) filter?: ContentWageFilter, + @CurrentUser() user?: User + ) { + return await this.contentWageService.getContentWage(content.id, user?.id, filter); + } +} diff --git a/src/backend/src/content/content/content.service.ts b/src/backend/src/content/content/content.service.ts new file mode 100644 index 00000000..d55b0a04 --- /dev/null +++ b/src/backend/src/content/content/content.service.ts @@ -0,0 +1,153 @@ +import { Injectable } from "@nestjs/common"; +import { Content, Prisma } from "@prisma/client"; +import { OrderByArg } from "src/common/object/order-by-arg.object"; +import { NotFoundException } from "src/common/exception/not-found.exception"; +import { PrismaService } from "src/prisma"; +import { DEFAULT_CONTENT_ORDER_BY } from "../shared/constants"; +import { + ContentListFilter, + ContentsFilter, + CreateContentInput, + CreateContentResult, +} from "./content.dto"; + +const RAID_CATEGORIES = ["에픽 레이드", "카제로스 레이드", "강습 레이드", "군단장 레이드"]; + +@Injectable() +export class ContentService { + constructor(private prisma: PrismaService) {} + + buildContentListWhere(filter?: ContentListFilter): Prisma.ContentWhereInput { + const where: Prisma.ContentWhereInput = {}; + + if (filter?.contentCategoryId) { + where.contentCategoryId = filter.contentCategoryId; + } + + if (filter?.keyword) { + where.OR = [ + { + name: { + contains: filter.keyword, + mode: "insensitive", + }, + }, + { + contentCategory: { + name: { + contains: filter.keyword, + mode: "insensitive", + }, + }, + }, + ]; + } + + if (filter?.status) { + where.status = filter.status; + } + + return where; + } + + buildContentsWhere(filter?: ContentsFilter): Prisma.ContentWhereInput { + const where: Prisma.ContentWhereInput = {}; + + if (filter?.ids) { + where.id = { + in: filter.ids, + }; + } + + return where; + } + + buildOrderBy(orderBy?: OrderByArg[]): Prisma.ContentOrderByWithRelationInput[] { + if (orderBy && orderBy.length > 0) { + return orderBy.map((order) => ({ [order.field]: order.order })); + } + + return DEFAULT_CONTENT_ORDER_BY; + } + + async createContent(input: CreateContentInput): Promise { + const { categoryId, contentRewards, contentSeeMoreRewards, duration, gate, level, name } = + input; + + const category = await this.prisma.contentCategory.findUnique({ + where: { id: categoryId }, + }); + + if (!category) { + throw new NotFoundException("ContentCategory", categoryId); + } + + const isRaid = this.isRaidCategory(category.name); + + await this.prisma.content.create({ + data: { + contentCategoryId: categoryId, + contentDuration: { + create: { + value: duration, + }, + }, + contentRewards: { + createMany: { + data: contentRewards.map(({ averageQuantity, isBound, itemId }) => ({ + averageQuantity, + isSellable: !isBound, + itemId, + })), + }, + }, + ...(isRaid && + contentSeeMoreRewards?.length && { + contentSeeMoreRewards: { + createMany: { + data: contentSeeMoreRewards.map(({ itemId, quantity }) => ({ + itemId, + quantity, + })), + }, + }, + }), + gate, + level, + name, + }, + }); + + return { ok: true }; + } + + async findContentById(id: number): Promise { + const content = await this.prisma.content.findUnique({ + where: { id }, + }); + + if (!content) { + throw new NotFoundException("Content", id); + } + + return content; + } + + async findContentList(filter?: ContentListFilter, orderBy?: OrderByArg[]): Promise { + return await this.prisma.content.findMany({ + orderBy: this.buildOrderBy(orderBy), + where: this.buildContentListWhere(filter), + }); + } + + async findContents(filter?: ContentsFilter): Promise { + return await this.prisma.content.findMany({ + orderBy: DEFAULT_CONTENT_ORDER_BY, + where: this.buildContentsWhere(filter), + }); + } + + isRaidCategory(categoryName: string): boolean { + return RAID_CATEGORIES.includes(categoryName); + } +} diff --git a/src/backend/src/content/duration/duration.dto.spec.ts b/src/backend/src/content/duration/duration.dto.spec.ts new file mode 100644 index 00000000..6810d270 --- /dev/null +++ b/src/backend/src/content/duration/duration.dto.spec.ts @@ -0,0 +1,83 @@ +import { validate } from "class-validator"; +import { plainToInstance } from "class-transformer"; +import { EditContentDurationInput } from "./duration.dto"; + +describe("EditContentDurationInput Validation", () => { + it("유효한 시간 형식을 허용해야 함", async () => { + const input = plainToInstance(EditContentDurationInput, { + contentId: 1, + minutes: 5, + seconds: 30, + }); + + const errors = await validate(input); + expect(errors).toHaveLength(0); + }); + + it("minutes가 59를 초과하면 에러", async () => { + const input = plainToInstance(EditContentDurationInput, { + contentId: 1, + minutes: 60, + seconds: 0, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("minutes"); + }); + + it("seconds가 59를 초과하면 에러", async () => { + const input = plainToInstance(EditContentDurationInput, { + contentId: 1, + minutes: 0, + seconds: 60, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("seconds"); + }); + + it("minutes와 seconds가 0-59 범위면 허용 (경계값)", async () => { + const input1 = plainToInstance(EditContentDurationInput, { + contentId: 1, + minutes: 0, + seconds: 0, + }); + const input2 = plainToInstance(EditContentDurationInput, { + contentId: 1, + minutes: 59, + seconds: 59, + }); + + const errors1 = await validate(input1); + const errors2 = await validate(input2); + + expect(errors1).toHaveLength(0); + expect(errors2).toHaveLength(0); + }); + + it("minutes가 음수면 에러", async () => { + const input = plainToInstance(EditContentDurationInput, { + contentId: 1, + minutes: -1, + seconds: 0, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("minutes"); + }); + + it("contentId가 0 이하면 에러", async () => { + const input = plainToInstance(EditContentDurationInput, { + contentId: 0, + minutes: 5, + seconds: 30, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("contentId"); + }); +}); diff --git a/src/backend/src/content/duration/duration.dto.ts b/src/backend/src/content/duration/duration.dto.ts new file mode 100644 index 00000000..64b3e2e7 --- /dev/null +++ b/src/backend/src/content/duration/duration.dto.ts @@ -0,0 +1,62 @@ +import { Field, InputType, Int, ObjectType } from "@nestjs/graphql"; +import { ArrayMinSize, IsArray, IsNumber, Max, Min, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; +import { MutationResult } from "src/common/dto/mutation-result.dto"; + +@InputType() +export class EditContentDurationInput { + @Field({ description: "컨텐츠 ID" }) + @IsNumber() + @Min(1) + contentId: number; + + @Field(() => Int, { description: "분" }) + @IsNumber() + @Min(0) + @Max(59) + minutes: number; + + @Field(() => Int, { description: "초" }) + @IsNumber() + @Min(0) + @Max(59) + seconds: number; +} + +@ObjectType() +export class EditContentDurationResult extends MutationResult {} + +@InputType() +export class EditContentDurationsDurationInput { + @Field({ description: "컨텐츠 ID" }) + @IsNumber() + @Min(1) + contentId: number; + + @Field(() => Int, { description: "분" }) + @IsNumber() + @Min(0) + @Max(59) + minutes: number; + + @Field(() => Int, { description: "초" }) + @IsNumber() + @Min(0) + @Max(59) + seconds: number; +} + +@InputType() +export class EditContentDurationsInput { + @Field(() => [EditContentDurationsDurationInput], { + description: "수정할 컨텐츠 소요 시간 목록", + }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => EditContentDurationsDurationInput) + contentDurations: EditContentDurationsDurationInput[]; +} + +@ObjectType() +export class EditContentDurationsResult extends MutationResult {} diff --git a/src/backend/src/content/object/user-content-duration.object.ts b/src/backend/src/content/duration/duration.object.ts similarity index 68% rename from src/backend/src/content/object/user-content-duration.object.ts rename to src/backend/src/content/duration/duration.object.ts index e64b47e9..38aa9879 100644 --- a/src/backend/src/content/object/user-content-duration.object.ts +++ b/src/backend/src/content/duration/duration.object.ts @@ -1,6 +1,15 @@ import { Field, ObjectType } from "@nestjs/graphql"; import { BaseObject } from "src/common/object/base.object"; +@ObjectType() +export class ContentDuration extends BaseObject { + @Field() + contentId: number; + + @Field() + value: number; +} + @ObjectType() export class UserContentDuration extends BaseObject { @Field() diff --git a/src/backend/src/content/duration/duration.resolver.ts b/src/backend/src/content/duration/duration.resolver.ts new file mode 100644 index 00000000..6f96b859 --- /dev/null +++ b/src/backend/src/content/duration/duration.resolver.ts @@ -0,0 +1,115 @@ +import { UseGuards } from "@nestjs/common"; +import { Args, Mutation, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; +import { UserRole } from "@prisma/client"; +import { AuthGuard } from "src/auth/auth.guard"; +import { CurrentUser } from "src/common/decorator/current-user.decorator"; +import { User } from "src/common/object/user.object"; +import { PrismaService } from "src/prisma"; +import { Content } from "../content/content.object"; +import { ContentDurationService } from "./duration.service"; +import { + EditContentDurationInput, + EditContentDurationResult, + EditContentDurationsInput, + EditContentDurationsResult, +} from "./duration.dto"; +import { ContentDuration } from "./duration.object"; + +@Resolver(() => ContentDuration) +export class DurationResolver { + constructor( + private prisma: PrismaService, + private contentDurationService: ContentDurationService + ) {} + + @Query(() => ContentDuration) + async contentDuration(@Args("id") id: number) { + return await this.prisma.contentDuration.findUniqueOrThrow({ + where: { + id, + }, + }); + } + + @UseGuards(AuthGuard) + @Mutation(() => EditContentDurationResult) + async contentDurationEdit( + @Args("input") input: EditContentDurationInput, + @CurrentUser() user: User + ) { + const { contentId, minutes, seconds } = input; + + const totalSeconds = this.contentDurationService.getValidatedTotalSeconds({ + minutes, + seconds, + }); + + return await this.prisma.$transaction(async (tx) => { + if (user.role === UserRole.OWNER) { + await tx.contentDuration.update({ + data: { + value: totalSeconds, + }, + where: { + contentId, + }, + }); + } + + await tx.userContentDuration.upsert({ + create: { contentId, userId: user.id, value: totalSeconds }, + update: { value: totalSeconds }, + where: { contentId_userId: { contentId, userId: user.id } }, + }); + + return { ok: true }; + }); + } + + @UseGuards(AuthGuard) + @Mutation(() => EditContentDurationsResult) + async contentDurationsEdit( + @Args("input") input: EditContentDurationsInput, + @CurrentUser() user: User + ) { + const { contentDurations } = input; + + await this.prisma.$transaction(async (tx) => { + const promises = contentDurations.map(async ({ contentId, minutes, seconds }) => { + const totalSeconds = this.contentDurationService.getValidatedTotalSeconds({ + minutes, + seconds, + }); + + if (user.role === UserRole.OWNER) { + await tx.contentDuration.update({ + data: { + value: totalSeconds, + }, + where: { + contentId, + }, + }); + } + + await tx.userContentDuration.upsert({ + create: { contentId, userId: user.id, value: totalSeconds }, + update: { value: totalSeconds }, + where: { contentId_userId: { contentId, userId: user.id } }, + }); + }); + + await Promise.all(promises); + }); + + return { ok: true }; + } + + @UseGuards(AuthGuard) + @ResolveField(() => Content) + async content(@Parent() contentDuration: ContentDuration) { + return await this.prisma.content.findUniqueOrThrow({ + where: { id: contentDuration.contentId }, + }); + } +} diff --git a/src/backend/src/content/service/content-duration.service.spec.ts b/src/backend/src/content/duration/duration.service.spec.ts similarity index 98% rename from src/backend/src/content/service/content-duration.service.spec.ts rename to src/backend/src/content/duration/duration.service.spec.ts index f7e06422..4de8fa09 100644 --- a/src/backend/src/content/service/content-duration.service.spec.ts +++ b/src/backend/src/content/duration/duration.service.spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from "@nestjs/testing"; -import { ContentDurationService } from "./content-duration.service"; +import { ContentDurationService } from "./duration.service"; import { UserInputError } from "apollo-server-express"; describe("ContentDurationService", () => { diff --git a/src/backend/src/content/service/content-duration.service.ts b/src/backend/src/content/duration/duration.service.ts similarity index 100% rename from src/backend/src/content/service/content-duration.service.ts rename to src/backend/src/content/duration/duration.service.ts diff --git a/src/backend/src/content/group/group.dto.ts b/src/backend/src/content/group/group.dto.ts new file mode 100644 index 00000000..b92cb64c --- /dev/null +++ b/src/backend/src/content/group/group.dto.ts @@ -0,0 +1,40 @@ +import { Field, InputType } from "@nestjs/graphql"; +import { IsArray, IsEnum, IsNumber, IsOptional, IsString, MaxLength, Min } from "class-validator"; +import { ContentStatus } from "@prisma/client"; +import { ContentWageFilter } from "../wage/wage.dto"; +import { CONTENT_NAME_MAX_LENGTH } from "../../common/constants/content.constants"; + +@InputType() +export class ContentGroupFilter { + @Field(() => [Number], { + description: "그룹화할 컨텐츠 ID 목록", + nullable: true, + }) + @IsOptional() + @IsArray() + @IsNumber({}, { each: true }) + @Min(1, { each: true }) + contentIds?: number[]; +} + +@InputType() +export class ContentGroupWageListFilter extends ContentWageFilter { + @Field({ + description: "필터링할 컨텐츠 카테고리 ID", + nullable: true, + }) + @IsOptional() + @IsNumber() + contentCategoryId?: number; + + @Field(() => String, { description: "검색 키워드", nullable: true }) + @IsOptional() + @IsString() + @MaxLength(CONTENT_NAME_MAX_LENGTH) + keyword?: string; + + @Field(() => ContentStatus, { description: "컨텐츠 상태", nullable: true }) + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; +} diff --git a/src/backend/src/content/object/content-group-wage.object.ts b/src/backend/src/content/group/group.object.ts similarity index 62% rename from src/backend/src/content/object/content-group-wage.object.ts rename to src/backend/src/content/group/group.object.ts index e2791f02..08210cfe 100644 --- a/src/backend/src/content/object/content-group-wage.object.ts +++ b/src/backend/src/content/group/group.object.ts @@ -1,5 +1,19 @@ import { Field, Float, ObjectType } from "@nestjs/graphql"; -import { ContentGroup } from "./content-group.object"; + +@ObjectType() +export class ContentGroup { + @Field() + contentCategoryId: number; + + @Field(() => [Number]) + contentIds: number[]; + + @Field() + level: number; + + @Field() + name: string; +} @ObjectType() export class ContentGroupWage { diff --git a/src/backend/src/content/query/content-group-wage-list.query.spec.ts b/src/backend/src/content/group/group.resolver.spec.ts similarity index 77% rename from src/backend/src/content/query/content-group-wage-list.query.spec.ts rename to src/backend/src/content/group/group.resolver.spec.ts index a131ad12..f2f46c85 100644 --- a/src/backend/src/content/query/content-group-wage-list.query.spec.ts +++ b/src/backend/src/content/group/group.resolver.spec.ts @@ -1,11 +1,13 @@ import { Test, TestingModule } from "@nestjs/testing"; import { PrismaService } from "src/prisma"; -import { ContentGroupWageListQuery } from "./content-group-wage-list.query"; -import { ContentWageService } from "../service/content-wage.service"; +import { GroupResolver } from "./group.resolver"; +import { GroupService } from "./group.service"; +import { ContentWageService } from "../wage/wage.service"; import { UserContentService } from "../../user/service/user-content.service"; import { CONTEXT } from "@nestjs/graphql"; import { UserGoldExchangeRateService } from "src/user/service/user-gold-exchange-rate.service"; import { ContentStatus } from "@prisma/client"; +import { DataLoaderService } from "src/dataloader/data-loader.service"; type MockContent = { contentCategory: { @@ -28,20 +30,60 @@ type MockContent = { updatedAt: Date; }; -describe("ContentGroupWageListQuery", () => { +describe("GroupResolver", () => { let module: TestingModule; let prisma: PrismaService; - let query: ContentGroupWageListQuery; + let resolver: GroupResolver; + let groupService: GroupService; let contentWageService: ContentWageService; beforeAll(async () => { + const mockPrismaService = { + content: { + findMany: jest.fn(), + }, + }; + + const mockContentWageService = { + getContentGroupWage: jest.fn(), + }; + + const mockUserContentService = { + getContentDuration: jest.fn(), + }; + + const mockUserGoldExchangeRateService = {}; + + const mockDataLoaderService = { + contentCategory: { + findUniqueOrThrowById: jest.fn(), + }, + }; + module = await Test.createTestingModule({ providers: [ - PrismaService, - ContentGroupWageListQuery, - ContentWageService, - UserContentService, - UserGoldExchangeRateService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + { + provide: ContentWageService, + useValue: mockContentWageService, + }, + { + provide: UserContentService, + useValue: mockUserContentService, + }, + { + provide: UserGoldExchangeRateService, + useValue: mockUserGoldExchangeRateService, + }, + { + provide: DataLoaderService, + useValue: mockDataLoaderService, + }, + GroupService, + GroupResolver, { provide: CONTEXT, useValue: { req: { user: { id: undefined } } }, @@ -50,7 +92,8 @@ describe("ContentGroupWageListQuery", () => { }).compile(); prisma = module.get(PrismaService); - query = module.get(ContentGroupWageListQuery); + resolver = module.get(GroupResolver); + groupService = module.get(GroupService); contentWageService = module.get(ContentWageService); }); @@ -58,6 +101,10 @@ describe("ContentGroupWageListQuery", () => { await module.close(); }); + beforeEach(() => { + jest.restoreAllMocks(); + }); + describe("contentGroupWageList", () => { describe("그룹핑 로직 정확성 검증", () => { it("같은 이름과 카테고리의 컨텐츠들이 올바르게 그룹핑되어야 한다", async () => { @@ -107,12 +154,11 @@ describe("ContentGroupWageListQuery", () => { krwAmountPerHour: 10000, }; - const mockFindMany = jest.fn().mockResolvedValue(mockContents); - prisma.content.findMany = mockFindMany; + jest.spyOn(prisma.content, "findMany").mockResolvedValue(mockContents as any); jest.spyOn(contentWageService, "getContentGroupWage").mockResolvedValue(mockWage); // When - const result = await query.contentGroupWageList(); + const result = await resolver.contentGroupWageList(); // Then expect(result).toHaveLength(2); @@ -131,7 +177,7 @@ describe("ContentGroupWageListQuery", () => { jest.spyOn(prisma.content, "findMany").mockResolvedValue([]); // When - const result = await query.contentGroupWageList(); + const result = await resolver.contentGroupWageList(); // Then expect(result).toEqual([]); @@ -144,7 +190,7 @@ describe("ContentGroupWageListQuery", () => { const filter = { contentCategoryId: 1 }; // When - const whereArgs = query.buildWhereArgs(filter); + const whereArgs = groupService.buildContentGroupWageListWhere(filter); // Then expect(whereArgs.contentCategoryId).toBe(1); @@ -155,7 +201,7 @@ describe("ContentGroupWageListQuery", () => { const filter = { keyword: "아브렐" }; // When - const whereArgs = query.buildWhereArgs(filter); + const whereArgs = groupService.buildContentGroupWageListWhere(filter); // Then expect(whereArgs.OR).toEqual([ @@ -181,7 +227,7 @@ describe("ContentGroupWageListQuery", () => { const filter = { status: ContentStatus.ACTIVE }; // When - const whereArgs = query.buildWhereArgs(filter); + const whereArgs = groupService.buildContentGroupWageListWhere(filter); // Then expect(whereArgs.status).toBe(ContentStatus.ACTIVE); @@ -189,7 +235,7 @@ describe("ContentGroupWageListQuery", () => { it("buildWhereArgs - 필터가 없을 때 빈 객체를 반환해야 한다", () => { // When - const whereArgs = query.buildWhereArgs(); + const whereArgs = groupService.buildContentGroupWageListWhere(); // Then expect(whereArgs).toEqual({}); @@ -232,12 +278,11 @@ describe("ContentGroupWageListQuery", () => { krwAmountPerHour: 10000, }; - const mockFindMany = jest.fn().mockResolvedValue(mockContents); - prisma.content.findMany = mockFindMany; + jest.spyOn(prisma.content, "findMany").mockResolvedValue(mockContents as any); jest.spyOn(contentWageService, "getContentGroupWage").mockResolvedValue(mockWage); // When - const result = await query.contentGroupWageList(); + const result = await resolver.contentGroupWageList(); // Then expect(result).toHaveLength(2); @@ -274,8 +319,7 @@ describe("ContentGroupWageListQuery", () => { }, ]; - const mockFindMany = jest.fn().mockResolvedValue(mockContents); - prisma.content.findMany = mockFindMany; + jest.spyOn(prisma.content, "findMany").mockResolvedValue(mockContents as any); jest .spyOn(contentWageService, "getContentGroupWage") .mockResolvedValueOnce({ @@ -292,7 +336,7 @@ describe("ContentGroupWageListQuery", () => { const orderBy = [{ field: "krwAmountPerHour", order: "desc" as const }]; // When - const result = await query.contentGroupWageList(undefined, orderBy); + const result = await resolver.contentGroupWageList(undefined, orderBy); // Then expect(result).toHaveLength(2); diff --git a/src/backend/src/content/group/group.resolver.ts b/src/backend/src/content/group/group.resolver.ts new file mode 100644 index 00000000..5b6a767c --- /dev/null +++ b/src/backend/src/content/group/group.resolver.ts @@ -0,0 +1,62 @@ +import { Args, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; +import { User } from "@prisma/client"; +import { CurrentUser } from "src/common/decorator/current-user.decorator"; +import { OrderByArg } from "src/common/object/order-by-arg.object"; +import { DataLoaderService } from "src/dataloader/data-loader.service"; +import { ContentCategory } from "../category/category.object"; +import { Content } from "../content/content.object"; +import { ContentGroupFilter, ContentGroupWageListFilter } from "./group.dto"; +import { GroupService } from "./group.service"; +import { ContentGroup, ContentGroupWage } from "./group.object"; + +@Resolver(() => ContentGroup) +export class GroupResolver { + constructor( + private groupService: GroupService, + private dataLoaderService: DataLoaderService + ) {} + + @Query(() => ContentGroup) + async contentGroup(@Args("filter", { nullable: true }) filter?: ContentGroupFilter) { + return await this.groupService.findContentGroup(filter); + } + + @Query(() => [ContentGroupWage]) + async contentGroupWageList( + @Args("filter", { nullable: true }) filter?: ContentGroupWageListFilter, + @Args("orderBy", { + nullable: true, + type: () => [OrderByArg], + }) + orderBy?: OrderByArg[], + @CurrentUser() user?: User + ) { + return await this.groupService.findContentGroupWageList(filter, orderBy, user?.id); + } + + @ResolveField(() => ContentCategory) + async contentCategory(@Parent() contentGroup: ContentGroup) { + return await this.dataLoaderService.contentCategory.findUniqueOrThrowById( + contentGroup.contentCategoryId + ); + } + + @ResolveField(() => [Content]) + async contents(@Parent() contentGroup: ContentGroup) { + return await this.groupService.findContentsByIds(contentGroup.contentIds); + } + + @ResolveField(() => Number) + async duration(@Parent() contentGroup: ContentGroup) { + return await this.groupService.calculateGroupDuration(contentGroup.contentIds); + } + + @ResolveField(() => String) + async durationText(@Parent() contentGroup: ContentGroup) { + const durationInSeconds = await this.duration(contentGroup); + const minutes = Math.floor(durationInSeconds / 60); + const seconds = durationInSeconds % 60; + + return seconds === 0 ? `${minutes}분` : `${minutes}분 ${seconds}초`; + } +} diff --git a/src/backend/src/content/group/group.service.spec.ts b/src/backend/src/content/group/group.service.spec.ts new file mode 100644 index 00000000..95a91938 --- /dev/null +++ b/src/backend/src/content/group/group.service.spec.ts @@ -0,0 +1,390 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { ContentStatus } from "@prisma/client"; +import { ValidationException } from "src/common/exception"; +import { PrismaService } from "src/prisma"; +import { UserContentService } from "src/user/service/user-content.service"; +import { ContentWageService } from "../wage/wage.service"; +import { GroupService } from "./group.service"; + +describe("GroupService", () => { + let module: TestingModule; + let service: GroupService; + let prisma: PrismaService; + let userContentService: UserContentService; + let contentWageService: ContentWageService; + + beforeAll(async () => { + const mockPrismaService = { + content: { + findMany: jest.fn(), + }, + }; + + const mockUserContentService = { + getContentDuration: jest.fn(), + }; + + const mockContentWageService = { + getContentGroupWage: jest.fn(), + }; + + module = await Test.createTestingModule({ + providers: [ + GroupService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + { + provide: UserContentService, + useValue: mockUserContentService, + }, + { + provide: ContentWageService, + useValue: mockContentWageService, + }, + ], + }).compile(); + + service = module.get(GroupService); + prisma = module.get(PrismaService); + userContentService = module.get(UserContentService); + contentWageService = module.get(ContentWageService); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("buildContentGroupWageListWhere", () => { + it("should return empty object when no filter provided", () => { + const result = service.buildContentGroupWageListWhere(); + expect(result).toEqual({}); + }); + + it("should apply contentCategoryId filter", () => { + const result = service.buildContentGroupWageListWhere({ contentCategoryId: 1 }); + expect(result.contentCategoryId).toBe(1); + }); + + it("should apply keyword filter with OR condition", () => { + const result = service.buildContentGroupWageListWhere({ keyword: "아브렐" }); + expect(result.OR).toEqual([ + { + name: { + contains: "아브렐", + mode: "insensitive", + }, + }, + { + contentCategory: { + name: { + contains: "아브렐", + mode: "insensitive", + }, + }, + }, + ]); + }); + + it("should apply status filter", () => { + const result = service.buildContentGroupWageListWhere({ status: ContentStatus.ACTIVE }); + expect(result.status).toBe(ContentStatus.ACTIVE); + }); + + it("should apply multiple filters", () => { + const result = service.buildContentGroupWageListWhere({ + contentCategoryId: 1, + keyword: "test", + status: ContentStatus.ACTIVE, + }); + expect(result.contentCategoryId).toBe(1); + expect(result.status).toBe(ContentStatus.ACTIVE); + expect(result.OR).toBeDefined(); + }); + }); + + describe("buildContentGroupWhere", () => { + it("should return empty object when no filter provided", () => { + const result = service.buildContentGroupWhere(); + expect(result).toEqual({}); + }); + + it("should apply contentIds filter", () => { + const result = service.buildContentGroupWhere({ contentIds: [1, 2, 3] }); + expect(result.id).toEqual({ in: [1, 2, 3] }); + }); + }); + + describe("findContentsByIds", () => { + it("should find contents by IDs", async () => { + const mockContents = [ + { id: 1, name: "Content 1" }, + { id: 2, name: "Content 2" }, + ]; + + jest.spyOn(prisma.content, "findMany").mockResolvedValue(mockContents as any); + + const result = await service.findContentsByIds([1, 2]); + + expect(prisma.content.findMany).toHaveBeenCalledWith({ + where: { id: { in: [1, 2] } }, + }); + expect(result).toEqual(mockContents); + }); + }); + + describe("validateContentGroup", () => { + it("should not throw error for empty array", () => { + expect(() => service.validateContentGroup([])).not.toThrow(); + }); + + it("should not throw error for valid content group", () => { + const contents = [ + { contentCategoryId: 1, level: 1490 }, + { contentCategoryId: 1, level: 1490 }, + ]; + + expect(() => service.validateContentGroup(contents)).not.toThrow(); + }); + + it("should throw ValidationException when levels differ", () => { + const contents = [ + { contentCategoryId: 1, level: 1490 }, + { contentCategoryId: 1, level: 1500 }, + ]; + + expect(() => service.validateContentGroup(contents)).toThrow(ValidationException); + expect(() => service.validateContentGroup(contents)).toThrow("Content level is not the same"); + }); + + it("should throw ValidationException when categories differ", () => { + const contents = [ + { contentCategoryId: 1, level: 1490 }, + { contentCategoryId: 2, level: 1490 }, + ]; + + expect(() => service.validateContentGroup(contents)).toThrow(ValidationException); + expect(() => service.validateContentGroup(contents)).toThrow( + "Content category is not the same" + ); + }); + }); + + describe("groupContentsByNameAndCategory", () => { + it("should group contents by name and category", () => { + const contents = [ + { contentCategoryId: 1, id: 1, name: "아브렐슈드" }, + { contentCategoryId: 1, id: 2, name: "아브렐슈드" }, + { contentCategoryId: 1, id: 3, name: "쿠크세이튼" }, + ]; + + const result = service.groupContentsByNameAndCategory(contents); + + expect(Object.keys(result)).toHaveLength(2); + expect(result["아브렐슈드_1"]).toHaveLength(2); + expect(result["쿠크세이튼_1"]).toHaveLength(1); + }); + + it("should separate same name but different categories", () => { + const contents = [ + { contentCategoryId: 1, id: 1, name: "컨텐츠" }, + { contentCategoryId: 2, id: 2, name: "컨텐츠" }, + ]; + + const result = service.groupContentsByNameAndCategory(contents); + + expect(Object.keys(result)).toHaveLength(2); + expect(result["컨텐츠_1"]).toHaveLength(1); + expect(result["컨텐츠_2"]).toHaveLength(1); + }); + }); + + describe("calculateGroupDuration", () => { + it("should calculate total duration for content group", async () => { + jest.spyOn(userContentService, "getContentDuration").mockResolvedValueOnce(300); + jest.spyOn(userContentService, "getContentDuration").mockResolvedValueOnce(600); + + const result = await service.calculateGroupDuration([1, 2]); + + expect(userContentService.getContentDuration).toHaveBeenCalledTimes(2); + expect(userContentService.getContentDuration).toHaveBeenCalledWith(1); + expect(userContentService.getContentDuration).toHaveBeenCalledWith(2); + expect(result).toBe(900); + }); + + it("should return 0 for empty content IDs", async () => { + const result = await service.calculateGroupDuration([]); + expect(result).toBe(0); + expect(userContentService.getContentDuration).not.toHaveBeenCalled(); + }); + }); + + describe("findContentGroup", () => { + it("should find and validate content group", async () => { + const mockContents = [ + { contentCategoryId: 1, id: 1, level: 1490, name: "아브렐슈드" }, + { contentCategoryId: 1, id: 2, level: 1490, name: "아브렐슈드" }, + ]; + + jest.spyOn(prisma.content, "findMany").mockResolvedValue(mockContents as any); + + const result = await service.findContentGroup({ contentIds: [1, 2] }); + + expect(result).toEqual({ + contentCategoryId: 1, + contentIds: [1, 2], + level: 1490, + name: "아브렐슈드", + }); + }); + + it("should throw ValidationException for invalid content group", async () => { + const mockContents = [ + { contentCategoryId: 1, id: 1, level: 1490, name: "아브렐슈드" }, + { contentCategoryId: 1, id: 2, level: 1500, name: "아브렐슈드" }, + ]; + + jest.spyOn(prisma.content, "findMany").mockResolvedValue(mockContents as any); + + await expect(service.findContentGroup({ contentIds: [1, 2] })).rejects.toThrow( + ValidationException + ); + }); + }); + + describe("findContentGroupWageList", () => { + it("should return content group wage list without orderBy", async () => { + const mockContents = [ + { + contentCategory: { id: 1, name: "군단장" }, + contentCategoryId: 1, + contentSeeMoreRewards: [], + id: 1, + level: 1490, + name: "아브렐슈드", + }, + { + contentCategory: { id: 1, name: "군단장" }, + contentCategoryId: 1, + contentSeeMoreRewards: [], + id: 2, + level: 1500, + name: "아브렐슈드", + }, + ]; + + const mockWage = { + goldAmountPerClear: 5000, + goldAmountPerHour: 10000, + krwAmountPerHour: 15000, + }; + + jest.spyOn(prisma.content, "findMany").mockResolvedValue(mockContents as any); + jest.spyOn(contentWageService, "getContentGroupWage").mockResolvedValue(mockWage); + + const result = await service.findContentGroupWageList(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + contentGroup: { + contentCategoryId: 1, + contentIds: [1, 2], + level: 1490, + name: "아브렐슈드", + }, + goldAmountPerClear: 5000, + goldAmountPerHour: 10000, + krwAmountPerHour: 15000, + }); + }); + + it("should return sorted content group wage list with orderBy", async () => { + const mockContents = [ + { + contentCategory: { id: 1, name: "군단장" }, + contentCategoryId: 1, + contentSeeMoreRewards: [], + id: 1, + level: 1490, + name: "A컨텐츠", + }, + { + contentCategory: { id: 1, name: "군단장" }, + contentCategoryId: 1, + contentSeeMoreRewards: [], + id: 2, + level: 1500, + name: "B컨텐츠", + }, + ]; + + jest.spyOn(prisma.content, "findMany").mockResolvedValue(mockContents as any); + jest + .spyOn(contentWageService, "getContentGroupWage") + .mockResolvedValueOnce({ + goldAmountPerClear: 3000, + goldAmountPerHour: 6000, + krwAmountPerHour: 9000, + }) + .mockResolvedValueOnce({ + goldAmountPerClear: 5000, + goldAmountPerHour: 10000, + krwAmountPerHour: 15000, + }); + + const result = await service.findContentGroupWageList(undefined, [ + { field: "krwAmountPerHour", order: "desc" }, + ]); + + expect(result).toHaveLength(2); + expect(result[0].krwAmountPerHour).toBe(15000); + expect(result[1].krwAmountPerHour).toBe(9000); + }); + + it("should return empty array when no contents found", async () => { + jest.spyOn(prisma.content, "findMany").mockResolvedValue([]); + + const result = await service.findContentGroupWageList(); + + expect(result).toEqual([]); + expect(contentWageService.getContentGroupWage).not.toHaveBeenCalled(); + }); + + it("should pass filter options to getContentGroupWage", async () => { + const mockContents = [ + { + contentCategory: { id: 1, name: "군단장" }, + contentCategoryId: 1, + contentSeeMoreRewards: [], + id: 1, + level: 1490, + name: "아브렐슈드", + }, + ]; + + jest.spyOn(prisma.content, "findMany").mockResolvedValue(mockContents as any); + jest.spyOn(contentWageService, "getContentGroupWage").mockResolvedValue({ + goldAmountPerClear: 5000, + goldAmountPerHour: 10000, + krwAmountPerHour: 15000, + }); + + await service.findContentGroupWageList({ + includeBound: false, + includeItemIds: [1, 2, 3], + includeSeeMore: true, + }); + + expect(contentWageService.getContentGroupWage).toHaveBeenCalledWith([1], undefined, { + includeBound: false, + includeItemIds: [1, 2, 3], + includeSeeMore: true, + }); + }); + }); +}); diff --git a/src/backend/src/content/group/group.service.ts b/src/backend/src/content/group/group.service.ts new file mode 100644 index 00000000..3337b99b --- /dev/null +++ b/src/backend/src/content/group/group.service.ts @@ -0,0 +1,185 @@ +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { groupBy, sum } from "es-toolkit"; +import { orderBy } from "es-toolkit/compat"; +import { ValidationException } from "src/common/exception"; +import { OrderByArg } from "src/common/object/order-by-arg.object"; +import { PrismaService } from "src/prisma"; +import { UserContentService } from "src/user/service/user-content.service"; +import { DEFAULT_CONTENT_ORDER_BY } from "../shared/constants"; +import { ContentWageService } from "../wage/wage.service"; +import { ContentGroupFilter, ContentGroupWageListFilter } from "./group.dto"; +import { ContentGroup, ContentGroupWage } from "./group.object"; + +type ContentGroupable = { + contentCategoryId: number; + id: number; + level: number; + name: string; +}; + +@Injectable() +export class GroupService { + constructor( + private prisma: PrismaService, + private userContentService: UserContentService, + private contentWageService: ContentWageService + ) {} + + buildContentGroupWageListWhere(filter?: ContentGroupWageListFilter): Prisma.ContentWhereInput { + const where: Prisma.ContentWhereInput = {}; + + if (filter?.contentCategoryId) { + where.contentCategoryId = filter.contentCategoryId; + } + + if (filter?.keyword) { + where.OR = [ + { + name: { + contains: filter.keyword, + mode: "insensitive", + }, + }, + { + contentCategory: { + name: { + contains: filter.keyword, + mode: "insensitive", + }, + }, + }, + ]; + } + + if (filter?.status) { + where.status = filter.status; + } + + return where; + } + + buildContentGroupWhere(filter?: ContentGroupFilter): Prisma.ContentWhereInput { + const where: Prisma.ContentWhereInput = {}; + + if (filter?.contentIds) { + where.id = { + in: filter.contentIds, + }; + } + + return where; + } + + async calculateGroupDuration(contentIds: number[]): Promise { + const durations = await Promise.all( + contentIds.map((contentId) => this.userContentService.getContentDuration(contentId)) + ); + + return sum(durations); + } + + async findContentGroup(filter?: ContentGroupFilter): Promise { + const contents = await this.prisma.content.findMany({ + orderBy: this.getContentOrderBy(), + where: this.buildContentGroupWhere(filter), + }); + + this.validateContentGroup(contents); + + return { + contentCategoryId: contents[0].contentCategoryId, + contentIds: contents.map((content) => content.id), + level: contents[0].level, + name: contents[0].name, + }; + } + + async findContentGroupWageList( + filter?: ContentGroupWageListFilter, + orderByArgs?: OrderByArg[], + userId?: number + ): Promise { + const contents = await this.prisma.content.findMany({ + include: { + contentCategory: true, + contentSeeMoreRewards: { + include: { + item: true, + }, + }, + }, + orderBy: this.getContentOrderBy(), + where: this.buildContentGroupWageListWhere(filter), + }); + + const contentGroups = this.groupContentsByNameAndCategory(contents); + + const promises = Object.values(contentGroups).map(async (groupContents) => { + const contentIds = groupContents.map((content) => content.id); + const representative = groupContents[0]; + + const wage = await this.contentWageService.getContentGroupWage(contentIds, userId, { + includeBound: filter?.includeBound, + includeItemIds: filter?.includeItemIds, + includeSeeMore: filter?.includeSeeMore, + }); + + return { + contentGroup: { + contentCategoryId: representative.contentCategoryId, + contentIds, + level: representative.level, + name: representative.name, + }, + ...wage, + }; + }); + + const result = await Promise.all(promises); + + return orderByArgs ? this.sortResults(result, orderByArgs) : result; + } + + async findContentsByIds(contentIds: number[]) { + return await this.prisma.content.findMany({ + where: { + id: { in: contentIds }, + }, + }); + } + + groupContentsByNameAndCategory(contents: T[]): Record { + return groupBy(contents, (content) => `${content.name}_${content.contentCategoryId}`); + } + + validateContentGroup(contents: ContentGroupable[]): void { + if (contents.length === 0) { + return; + } + + const firstContent = contents[0]; + + for (const content of contents) { + if (content.level !== firstContent.level) { + throw new ValidationException("Content level is not the same", "level"); + } + + if (content.contentCategoryId !== firstContent.contentCategoryId) { + throw new ValidationException("Content category is not the same", "contentCategoryId"); + } + } + } + + private getContentOrderBy(): Prisma.ContentOrderByWithRelationInput[] { + return DEFAULT_CONTENT_ORDER_BY; + } + + private sortResults(results: ContentGroupWage[], orderByArgs: OrderByArg[]): ContentGroupWage[] { + return orderBy( + results, + orderByArgs.map((o) => o.field), + orderByArgs.map((o) => o.order) + ); + } +} diff --git a/src/backend/src/content/item/item.dto.ts b/src/backend/src/content/item/item.dto.ts new file mode 100644 index 00000000..8f7b0f15 --- /dev/null +++ b/src/backend/src/content/item/item.dto.ts @@ -0,0 +1,35 @@ +import { Field, Float, InputType, ObjectType } from "@nestjs/graphql"; +import { IsEnum, IsNumber, IsOptional, IsString, MaxLength, Min } from "class-validator"; +import { ItemKind } from "@prisma/client"; +import { ITEM_KEYWORD_MAX_LENGTH } from "src/common/constants/item.constants"; +import { MutationResult } from "src/common/dto/mutation-result.dto"; + +@InputType() +export class ItemsFilter { + @Field({ description: "제외할 아이템 이름", nullable: true }) + @IsOptional() + @IsString() + @MaxLength(ITEM_KEYWORD_MAX_LENGTH) + excludeItemName?: string; + + @Field(() => ItemKind, { description: "아이템 종류", nullable: true }) + @IsOptional() + @IsEnum(ItemKind) + kind?: ItemKind; +} + +@InputType() +export class EditUserItemPriceInput { + @Field({ description: "아이템 ID" }) + @IsNumber() + @Min(1) + id: number; + + @Field(() => Float, { description: "사용자 지정 가격" }) + @IsNumber() + @Min(0) + price: number; +} + +@ObjectType() +export class EditUserItemPriceResult extends MutationResult {} diff --git a/src/backend/src/content/object/user-item.object.ts b/src/backend/src/content/item/item.object.ts similarity index 58% rename from src/backend/src/content/object/user-item.object.ts rename to src/backend/src/content/item/item.object.ts index cfaa3a41..ba117404 100644 --- a/src/backend/src/content/object/user-item.object.ts +++ b/src/backend/src/content/item/item.object.ts @@ -1,6 +1,19 @@ import { Field, Float, ObjectType } from "@nestjs/graphql"; +import { ItemKind } from "@prisma/client"; import { BaseObject } from "src/common/object/base.object"; +@ObjectType() +export class Item extends BaseObject { + @Field() + imageUrl: string; + + @Field(() => ItemKind) + kind: ItemKind; + + @Field() + name: string; +} + @ObjectType() export class UserItem extends BaseObject { @Field() diff --git a/src/backend/src/content/item/item.resolver.ts b/src/backend/src/content/item/item.resolver.ts new file mode 100644 index 00000000..d9338fdc --- /dev/null +++ b/src/backend/src/content/item/item.resolver.ts @@ -0,0 +1,57 @@ +import { UseGuards } from "@nestjs/common"; +import { Args, Float, Mutation, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; +import { User as PrismaUser } from "@prisma/client"; +import { AuthGuard } from "src/auth/auth.guard"; +import { CurrentUser } from "src/common/decorator/current-user.decorator"; +import { User } from "src/common/object/user.object"; +import { PrismaService } from "src/prisma"; +import { UserContentService } from "src/user/service/user-content.service"; +import { EditUserItemPriceInput, EditUserItemPriceResult, ItemsFilter } from "./item.dto"; +import { ItemService } from "./item.service"; +import { Item, UserItem } from "./item.object"; + +@Resolver(() => Item) +export class ItemResolver { + constructor( + private itemService: ItemService, + private prisma: PrismaService, + private userContentService: UserContentService + ) {} + + @Query(() => Item) + async item(@Args("id") id: number) { + return await this.itemService.findItemById(id); + } + + @Query(() => [Item]) + async items(@Args("filter", { nullable: true }) filter?: ItemsFilter) { + return await this.itemService.findItems(filter); + } + + @UseGuards(AuthGuard) + @Mutation(() => EditUserItemPriceResult) + async userItemPriceEdit( + @Args("input") input: EditUserItemPriceInput, + @CurrentUser() user: PrismaUser + ) { + return await this.itemService.editUserItemPrice(input, user.id); + } + + @ResolveField(() => Float) + async price(@Parent() item: Item, @CurrentUser() user?: PrismaUser) { + return await this.userContentService.getItemPrice(item.id, user?.id); + } + + @UseGuards(AuthGuard) + @ResolveField(() => UserItem) + async userItem(@Parent() item: Item, @CurrentUser() user: User) { + return await this.prisma.userItem.findUniqueOrThrow({ + where: { + userId_itemId: { + itemId: item.id, + userId: user.id, + }, + }, + }); + } +} diff --git a/src/backend/src/content/item/item.service.ts b/src/backend/src/content/item/item.service.ts new file mode 100644 index 00000000..c315c817 --- /dev/null +++ b/src/backend/src/content/item/item.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "src/prisma"; +import { UserContentService } from "src/user/service/user-content.service"; +import { ItemSortOrder } from "../shared/constants"; +import { ItemsFilter, EditUserItemPriceInput, EditUserItemPriceResult } from "./item.dto"; +import { Item } from "./item.object"; + +@Injectable() +export class ItemService { + constructor( + private prisma: PrismaService, + private userContentService: UserContentService + ) {} + + buildItemsWhere(filter?: ItemsFilter): Prisma.ItemWhereInput { + const where: Prisma.ItemWhereInput = {}; + + if (filter?.kind) { + where.kind = filter.kind; + } + + if (filter?.excludeItemName) { + where.name = { + not: filter.excludeItemName, + }; + } + + return where; + } + + async editUserItemPrice( + input: EditUserItemPriceInput, + userId: number + ): Promise { + const { id, price } = input; + + await this.userContentService.validateUserItem(id, userId); + + await this.prisma.userItem.update({ + data: { price }, + where: { id }, + }); + + return { ok: true }; + } + + async findItemById(id: number): Promise { + return await this.prisma.item.findUniqueOrThrow({ + where: { + id, + }, + }); + } + + async findItems(filter?: ItemsFilter): Promise { + const items = await this.prisma.item.findMany({ + where: this.buildItemsWhere(filter), + }); + + return this.sortItemsByPredefinedOrder(items); + } + + private sortItemsByPredefinedOrder(items: Item[]): Item[] { + return items.sort((a, b) => { + const aOrder = ItemSortOrder[a.name] || 999; + const bOrder = ItemSortOrder[b.name] || 999; + return aOrder - bOrder; + }); + } +} diff --git a/src/backend/src/content/mutation/content-create.mutation.ts b/src/backend/src/content/mutation/content-create.mutation.ts deleted file mode 100644 index 192858bd..00000000 --- a/src/backend/src/content/mutation/content-create.mutation.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { UseGuards } from "@nestjs/common"; -import { Args, Field, Float, InputType, Mutation, ObjectType, Resolver } from "@nestjs/graphql"; -import { AuthGuard } from "src/auth/auth.guard"; -import { PrismaService } from "src/prisma"; - -@InputType() -export class ContentCreateInput { - @Field() - categoryId: number; - - @Field(() => [ContentCreateItemsInput]) - contentRewards: ContentCreateItemsInput[]; - - @Field(() => [ContentCreateSeeMoreRewardsInput], { nullable: true }) - contentSeeMoreRewards?: ContentCreateSeeMoreRewardsInput[]; - - @Field() - duration: number; - - @Field({ nullable: true }) - gate?: number | null; - - @Field() - level: number; - - @Field() - name: string; -} - -@InputType() -export class ContentCreateItemsInput { - @Field(() => Float) - averageQuantity: number; - - @Field() - isBound: boolean; - - @Field() - itemId: number; -} - -@InputType() -export class ContentCreateSeeMoreRewardsInput { - @Field() - itemId: number; - - @Field(() => Float) - quantity: number; -} - -@ObjectType() -class ContentCreateResult { - @Field(() => Boolean) - ok: boolean; -} - -@Resolver() -export class ContentCreateMutation { - constructor(private prisma: PrismaService) {} - - @UseGuards(AuthGuard) - @Mutation(() => ContentCreateResult) - async contentCreate(@Args("input") input: ContentCreateInput) { - const { categoryId, contentRewards, contentSeeMoreRewards, duration, gate, level, name } = - input; - - // 카테고리 정보 조회하여 레이드 여부 확인 - const category = await this.prisma.contentCategory.findUniqueOrThrow({ - where: { id: categoryId }, - }); - - // TODO: 레이드 유형인지 판단하여 더보기 보상 생성 여부를 판단하는데, 구조적으로 더 나은 방법 검토 필요. - const isRaid = ["에픽 레이드", "카제로스 레이드", "강습 레이드", "군단장 레이드"].includes( - category.name - ); - - await this.prisma.content.create({ - data: { - contentCategoryId: categoryId, - contentDuration: { - create: { - value: duration, - }, - }, - contentRewards: { - createMany: { - data: contentRewards.map(({ averageQuantity, isBound, itemId }) => ({ - averageQuantity, - isSellable: !isBound, - itemId, - })), - }, - }, - ...(isRaid && - contentSeeMoreRewards?.length && { - contentSeeMoreRewards: { - createMany: { - data: contentSeeMoreRewards.map(({ itemId, quantity }) => ({ - itemId, - quantity, - })), - }, - }, - }), - gate, - level, - name, - }, - }); - - return { ok: true }; - } -} diff --git a/src/backend/src/content/mutation/content-duration-edit.mutation.ts b/src/backend/src/content/mutation/content-duration-edit.mutation.ts deleted file mode 100644 index e1dc6501..00000000 --- a/src/backend/src/content/mutation/content-duration-edit.mutation.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { UseGuards } from "@nestjs/common"; -import { Args, Field, InputType, Int, Mutation, ObjectType, Resolver } from "@nestjs/graphql"; -import { AuthGuard } from "src/auth/auth.guard"; -import { PrismaService } from "src/prisma"; -import { CurrentUser } from "src/common/decorator/current-user.decorator"; -import { User } from "src/common/object/user.object"; -import { UserRole } from "@prisma/client"; -import { ContentDurationService } from "../service/content-duration.service"; - -@InputType() -class ContentDurationEditInput { - @Field() - contentId: number; - - @Field(() => Int) - minutes: number; - - @Field(() => Int) - seconds: number; -} - -@ObjectType() -class ContentDurationEditResult { - @Field(() => Boolean) - ok: boolean; -} - -@Resolver() -export class ContentDurationEditMutation { - constructor( - private prisma: PrismaService, - private contentDurationService: ContentDurationService - ) {} - - @UseGuards(AuthGuard) - @Mutation(() => ContentDurationEditResult) - async contentDurationEdit( - @Args("input") input: ContentDurationEditInput, - @CurrentUser() user: User - ) { - const { contentId, minutes, seconds } = input; - - const totalSeconds = this.contentDurationService.getValidatedTotalSeconds({ - minutes, - seconds, - }); - - return await this.prisma.$transaction(async (tx) => { - if (user.role === UserRole.OWNER) { - await tx.contentDuration.update({ - data: { - value: totalSeconds, - }, - where: { - contentId, - }, - }); - } - - await tx.userContentDuration.upsert({ - create: { contentId, userId: user.id, value: totalSeconds }, - update: { value: totalSeconds }, - where: { contentId_userId: { contentId, userId: user.id } }, - }); - - return { ok: true }; - }); - } -} diff --git a/src/backend/src/content/mutation/content-durations-edit.mutation.ts b/src/backend/src/content/mutation/content-durations-edit.mutation.ts deleted file mode 100644 index dd2624eb..00000000 --- a/src/backend/src/content/mutation/content-durations-edit.mutation.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { UseGuards } from "@nestjs/common"; -import { Args, Field, InputType, Int, Mutation, ObjectType, Resolver } from "@nestjs/graphql"; -import { AuthGuard } from "src/auth/auth.guard"; -import { PrismaService } from "src/prisma"; -import { CurrentUser } from "src/common/decorator/current-user.decorator"; -import { User } from "src/common/object/user.object"; -import { UserRole } from "@prisma/client"; -import { ContentDurationService } from "../service/content-duration.service"; - -@InputType() -class ContentDurationsEditInputDuration { - @Field() - contentId: number; - - @Field(() => Int) - minutes: number; - - @Field(() => Int) - seconds: number; -} - -@InputType() -class ContentDurationsEditInput { - @Field(() => [ContentDurationsEditInputDuration]) - contentDurations: ContentDurationsEditInputDuration[]; -} - -@ObjectType() -class ContentDurationsEditResult { - @Field(() => Boolean) - ok: boolean; -} - -@Resolver() -export class ContentDurationsEditMutation { - constructor( - private prisma: PrismaService, - private contentDurationService: ContentDurationService - ) {} - - @UseGuards(AuthGuard) - @Mutation(() => ContentDurationsEditResult) - async contentDurationsEdit( - @Args("input") input: ContentDurationsEditInput, - @CurrentUser() user: User - ) { - const { contentDurations } = input; - - await this.prisma.$transaction(async (tx) => { - const promises = contentDurations.map(async ({ contentId, minutes, seconds }) => { - const totalSeconds = this.contentDurationService.getValidatedTotalSeconds({ - minutes, - seconds, - }); - - if (user.role === UserRole.OWNER) { - await tx.contentDuration.update({ - data: { - value: totalSeconds, - }, - where: { - contentId, - }, - }); - } - - await tx.userContentDuration.upsert({ - create: { contentId, userId: user.id, value: totalSeconds }, - update: { value: totalSeconds }, - where: { contentId_userId: { contentId, userId: user.id } }, - }); - }); - - await Promise.all(promises); - }); - - return { ok: true }; - } -} diff --git a/src/backend/src/content/mutation/content-rewards-edit.mutation.ts b/src/backend/src/content/mutation/content-rewards-edit.mutation.ts deleted file mode 100644 index db368c48..00000000 --- a/src/backend/src/content/mutation/content-rewards-edit.mutation.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { UseGuards } from "@nestjs/common"; -import { Args, Field, Float, InputType, Mutation, ObjectType, Resolver } from "@nestjs/graphql"; -import { AuthGuard } from "src/auth/auth.guard"; -import { PrismaService } from "src/prisma"; -import { CurrentUser } from "src/common/decorator/current-user.decorator"; -import { User } from "src/common/object/user.object"; -import { UserRole } from "@prisma/client"; - -@InputType() -class ContentRewardEditInput { - @Field(() => Float) - averageQuantity: number; - - @Field() - contentId: number; - - @Field(() => Boolean) - isSellable: boolean; - - @Field() - itemId: number; -} - -@InputType() -export class ContentRewardsEditInput { - @Field(() => [ContentRewardEditInput]) - contentRewards: ContentRewardEditInput[]; - - @Field() - isReportable: boolean; -} - -@ObjectType() -class ContentRewardsEditResult { - @Field(() => Boolean) - ok: boolean; -} - -@Resolver() -export class ContentRewardsEditMutation { - constructor(private prisma: PrismaService) {} - - @UseGuards(AuthGuard) - @Mutation(() => ContentRewardsEditResult) - async contentRewardsEdit( - @Args("input") input: ContentRewardsEditInput, - @CurrentUser() user: User - ) { - return await this.prisma.$transaction(async (tx) => { - if (user.role === UserRole.OWNER) { - await Promise.all( - input.contentRewards.map(async ({ averageQuantity, contentId, isSellable, itemId }) => { - await tx.contentReward.update({ - data: { - averageQuantity, - isSellable, - }, - where: { - contentId_itemId: { - contentId, - itemId, - }, - }, - }); - }) - ); - } - - await Promise.all( - input.contentRewards.map(({ averageQuantity, contentId, isSellable, itemId }) => - tx.userContentReward.upsert({ - create: { - averageQuantity, - contentId, - isSellable, - itemId, - userId: user.id, - }, - update: { averageQuantity, isSellable }, - where: { - userId_contentId_itemId: { contentId, itemId, userId: user.id }, - }, - }) - ) - ); - - if (input.isReportable) { - await Promise.all( - input.contentRewards.map(async ({ averageQuantity, contentId, itemId }) => { - const contentReward = await tx.contentReward.findUniqueOrThrow({ - where: { contentId_itemId: { contentId, itemId } }, - }); - - return tx.reportedContentReward.create({ - data: { - averageQuantity, - contentRewardId: contentReward.id, - userId: user.id, - }, - }); - }) - ); - } - - return { ok: true }; - }); - } -} diff --git a/src/backend/src/content/mutation/content-rewards-report.mutation.ts b/src/backend/src/content/mutation/content-rewards-report.mutation.ts deleted file mode 100644 index 0f4fabe9..00000000 --- a/src/backend/src/content/mutation/content-rewards-report.mutation.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { UseGuards } from "@nestjs/common"; -import { Args, Field, Float, InputType, Mutation, ObjectType, Resolver } from "@nestjs/graphql"; -import { AuthGuard } from "src/auth/auth.guard"; -import { PrismaService } from "src/prisma"; -import { CurrentUser } from "src/common/decorator/current-user.decorator"; -import { User } from "src/common/object/user.object"; - -@InputType() -class ContentRewardReportInput { - @Field(() => Float) - averageQuantity: number; - - @Field() - id: number; -} - -@InputType() -export class ContentRewardsReportInput { - @Field(() => [ContentRewardReportInput]) - contentRewards: ContentRewardReportInput[]; -} - -@ObjectType() -class ContentRewardsReportResult { - @Field(() => Boolean) - ok: boolean; -} - -@Resolver() -export class ContentRewardsReportMutation { - constructor(private prisma: PrismaService) {} - - @UseGuards(AuthGuard) - @Mutation(() => ContentRewardsReportResult) - async contentRewardsReport( - @Args("input") input: ContentRewardsReportInput, - @CurrentUser() user: User - ) { - return await this.prisma.$transaction(async (tx) => { - await Promise.all( - input.contentRewards.map(async ({ averageQuantity, id }) => { - return tx.reportedContentReward.create({ - data: { - averageQuantity, - contentRewardId: id, - userId: user.id, - }, - }); - }) - ); - - return { ok: true }; - }); - } -} diff --git a/src/backend/src/content/mutation/content-see-more-rewards-edit.mutation.ts b/src/backend/src/content/mutation/content-see-more-rewards-edit.mutation.ts deleted file mode 100644 index 3b70b875..00000000 --- a/src/backend/src/content/mutation/content-see-more-rewards-edit.mutation.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { UseGuards } from "@nestjs/common"; -import { Args, Field, Float, InputType, Mutation, ObjectType, Resolver } from "@nestjs/graphql"; -import { AuthGuard } from "src/auth/auth.guard"; -import { PrismaService } from "src/prisma"; -import { CurrentUser } from "src/common/decorator/current-user.decorator"; -import { User } from "src/common/object/user.object"; -import { UserRole } from "@prisma/client"; - -@InputType() -class ContentSeeMoreRewardEditInput { - @Field() - contentId: number; - - @Field() - itemId: number; - - @Field(() => Float) - quantity: number; -} - -@InputType() -export class ContentSeeMoreRewardsEditInput { - @Field(() => [ContentSeeMoreRewardEditInput]) - contentSeeMoreRewards: ContentSeeMoreRewardEditInput[]; -} - -@ObjectType() -class ContentSeeMoreRewardsEditResult { - @Field(() => Boolean) - ok: boolean; -} - -@Resolver() -export class ContentSeeMoreRewardsEditMutation { - constructor(private prisma: PrismaService) {} - - @UseGuards(AuthGuard) - @Mutation(() => ContentSeeMoreRewardsEditResult) - async contentSeeMoreRewardsEdit( - @Args("input") input: ContentSeeMoreRewardsEditInput, - @CurrentUser() user: User - ) { - return await this.prisma.$transaction(async (tx) => { - if (user.role === UserRole.OWNER) { - await Promise.all( - input.contentSeeMoreRewards.map(async ({ contentId, itemId, quantity }) => { - await tx.contentSeeMoreReward.update({ - data: { - quantity, - }, - where: { - contentId_itemId: { contentId, itemId }, - }, - }); - }) - ); - } - - await Promise.all( - input.contentSeeMoreRewards.map(({ contentId, itemId, quantity }) => - tx.userContentSeeMoreReward.upsert({ - create: { contentId, itemId, quantity, userId: user.id }, - update: { quantity }, - where: { - userId_contentId_itemId: { contentId, itemId, userId: user.id }, - }, - }) - ) - ); - - return { ok: true }; - }); - } -} diff --git a/src/backend/src/content/mutation/custom-content-wage-calculate.mutation.ts b/src/backend/src/content/mutation/custom-content-wage-calculate.mutation.ts deleted file mode 100644 index 549bd5c0..00000000 --- a/src/backend/src/content/mutation/custom-content-wage-calculate.mutation.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - Args, - Field, - Float, - InputType, - Int, - Mutation, - ObjectType, - Resolver, -} from "@nestjs/graphql"; -import { ContentWageService } from "../service/content-wage.service"; -import { ContentDurationService } from "../service/content-duration.service"; - -@InputType() -class CustomContentWageCalculateInput { - @Field(() => [CustomContentWageCalculateItemsInput]) - items: CustomContentWageCalculateItemsInput[]; - - @Field(() => Int) - minutes: number; - - @Field(() => Int) - seconds: number; -} - -@InputType() -class CustomContentWageCalculateItemsInput { - @Field() - id: number; - - @Field(() => Float) - quantity: number; -} - -@ObjectType() -class CustomContentWageCalculateResult { - @Field() - goldAmountPerClear: number; - - @Field() - goldAmountPerHour: number; - - @Field() - krwAmountPerHour: number; - - @Field(() => Boolean) - ok: boolean; -} - -@Resolver() -export class CustomContentWageCalculateMutation { - constructor( - private contentWageService: ContentWageService, - private contentDurationService: ContentDurationService - ) {} - - @Mutation(() => CustomContentWageCalculateResult) - async customContentWageCalculate(@Args("input") input: CustomContentWageCalculateInput) { - const { items, minutes, seconds } = input; - - const totalSeconds = this.contentDurationService.getValidatedTotalSeconds({ - minutes, - seconds, - }); - - const rewardsGold = await this.contentWageService.calculateGold( - items.map((item) => ({ - averageQuantity: item.quantity, - itemId: item.id, - })) - ); - - const { goldAmountPerHour, krwAmountPerHour } = await this.contentWageService.calculateWage({ - duration: totalSeconds, - gold: rewardsGold, - }); - - return { - goldAmountPerClear: Math.round(rewardsGold), - goldAmountPerHour, - krwAmountPerHour, - ok: true, - }; - } -} diff --git a/src/backend/src/content/mutation/user-item-price-edit.mutation.ts b/src/backend/src/content/mutation/user-item-price-edit.mutation.ts deleted file mode 100644 index 758d9319..00000000 --- a/src/backend/src/content/mutation/user-item-price-edit.mutation.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { UseGuards } from "@nestjs/common"; -import { Args, Field, Float, InputType, Mutation, ObjectType, Resolver } from "@nestjs/graphql"; -import { AuthGuard } from "src/auth/auth.guard"; -import { PrismaService } from "src/prisma"; -import { UserContentService } from "../../user/service/user-content.service"; - -@InputType() -class UserItemPriceEditInput { - @Field() - id: number; - - @Field(() => Float) - price: number; -} - -@ObjectType() -class UserItemPriceEditResult { - @Field(() => Boolean) - ok: boolean; -} - -@Resolver() -export class UserItemPriceEditMutation { - constructor( - private prisma: PrismaService, - private userContentService: UserContentService - ) {} - - @UseGuards(AuthGuard) - @Mutation(() => UserItemPriceEditResult) - async userItemPriceEdit(@Args("input") input: UserItemPriceEditInput) { - const { id, price } = input; - - await this.userContentService.validateUserItem(id); - - await this.prisma.userItem.update({ - data: { price }, - where: { id }, - }); - - return { ok: true }; - } -} diff --git a/src/backend/src/content/object/content-duration.object.ts b/src/backend/src/content/object/content-duration.object.ts deleted file mode 100644 index 39d7efb0..00000000 --- a/src/backend/src/content/object/content-duration.object.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { BaseObject } from "src/common/object/base.object"; - -@ObjectType() -export class ContentDuration extends BaseObject { - @Field() - contentId: number; - - @Field() - value: number; -} diff --git a/src/backend/src/content/object/content-duration.resolver.ts b/src/backend/src/content/object/content-duration.resolver.ts deleted file mode 100644 index 931157cf..00000000 --- a/src/backend/src/content/object/content-duration.resolver.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Parent, ResolveField, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { ContentDuration } from "./content-duration.object"; -import { UseGuards } from "@nestjs/common"; -import { AuthGuard } from "src/auth/auth.guard"; -import { Content } from "./content.object"; - -@Resolver(() => ContentDuration) -export class ContentDurationResolver { - constructor(private prisma: PrismaService) {} - - @UseGuards(AuthGuard) - @ResolveField(() => Content) - async content(@Parent() contentDuration: ContentDuration) { - return await this.prisma.content.findUniqueOrThrow({ - where: { id: contentDuration.contentId }, - }); - } -} diff --git a/src/backend/src/content/object/content-group.object.ts b/src/backend/src/content/object/content-group.object.ts deleted file mode 100644 index b0563026..00000000 --- a/src/backend/src/content/object/content-group.object.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; - -@ObjectType() -export class ContentGroup { - @Field() - contentCategoryId: number; - - @Field(() => [Number]) - contentIds: number[]; - - @Field() - level: number; - - @Field() - name: string; -} diff --git a/src/backend/src/content/object/content-group.resolver.ts b/src/backend/src/content/object/content-group.resolver.ts deleted file mode 100644 index 2c8e1ad1..00000000 --- a/src/backend/src/content/object/content-group.resolver.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Parent, ResolveField, Resolver } from "@nestjs/graphql"; -import { ContentCategory } from "./content-category.object"; -import { UserContentService } from "../../user/service/user-content.service"; -import { DataLoaderService } from "src/dataloader/data-loader.service"; -import { ContentGroup } from "./content-group.object"; -import { Content } from "./content.object"; -import { PrismaService } from "src/prisma"; - -@Resolver(() => ContentGroup) -export class ContentGroupResolver { - constructor( - private userContentService: UserContentService, - private dataLoaderService: DataLoaderService, - private prisma: PrismaService - ) {} - - @ResolveField(() => ContentCategory) - async contentCategory(@Parent() contentGroup: ContentGroup) { - return await this.dataLoaderService.contentCategory.findUniqueOrThrowById( - contentGroup.contentCategoryId - ); - } - - @ResolveField(() => [Content]) - async contents(@Parent() contentGroup: ContentGroup) { - return await this.prisma.content.findMany({ - where: { - id: { in: contentGroup.contentIds }, - }, - }); - } - - @ResolveField(() => Number) - async duration(@Parent() contentGroup: ContentGroup) { - let duration = 0; - - for (const contentId of contentGroup.contentIds) { - const contentDuration = await this.userContentService.getContentDuration(contentId); - duration += contentDuration; - } - - return duration; - } - - @ResolveField(() => String) - async durationText(@Parent() contentGroup: ContentGroup) { - const durationInSeconds = await this.duration(contentGroup); - const minutes = Math.floor(durationInSeconds / 60); - const seconds = durationInSeconds % 60; - - return seconds === 0 ? `${minutes}분` : `${minutes}분 ${seconds}초`; - } -} diff --git a/src/backend/src/content/object/content-reward.object.ts b/src/backend/src/content/object/content-reward.object.ts deleted file mode 100644 index bbce5ef0..00000000 --- a/src/backend/src/content/object/content-reward.object.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { BaseObject } from "src/common/object/base.object"; - -@ObjectType() -export class ContentReward extends BaseObject { - @Field() - itemId: number; -} diff --git a/src/backend/src/content/object/content-reward.resolver.ts b/src/backend/src/content/object/content-reward.resolver.ts deleted file mode 100644 index 617523c0..00000000 --- a/src/backend/src/content/object/content-reward.resolver.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Float, Parent, ResolveField, Resolver } from "@nestjs/graphql"; -import { ContentReward } from "./content-reward.object"; -import { Item } from "./item.object"; -import { UserContentService } from "../../user/service/user-content.service"; -import { DataLoaderService } from "src/dataloader/data-loader.service"; - -@Resolver(() => ContentReward) -export class ContentRewardResolver { - constructor( - private userContentService: UserContentService, - private dataLoaderService: DataLoaderService - ) {} - - @ResolveField(() => Float) - async averageQuantity(@Parent() contentReward: ContentReward) { - return await this.userContentService.getContentRewardAverageQuantity(contentReward.id); - } - - @ResolveField(() => Boolean) - async isSellable(@Parent() contentReward: ContentReward) { - return await this.userContentService.getContentRewardIsSellable(contentReward.id); - } - - @ResolveField(() => Item) - async item(@Parent() contentReward: ContentReward) { - return await this.dataLoaderService.item.findUniqueOrThrowById(contentReward.itemId); - } -} diff --git a/src/backend/src/content/object/content-see-more-reward.object.ts b/src/backend/src/content/object/content-see-more-reward.object.ts deleted file mode 100644 index e520bd8f..00000000 --- a/src/backend/src/content/object/content-see-more-reward.object.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { BaseObject } from "src/common/object/base.object"; - -@ObjectType() -export class ContentSeeMoreReward extends BaseObject { - @Field() - contentId: number; - - @Field() - itemId: number; -} diff --git a/src/backend/src/content/object/content-see-more-reward.resolver.ts b/src/backend/src/content/object/content-see-more-reward.resolver.ts deleted file mode 100644 index 932419c1..00000000 --- a/src/backend/src/content/object/content-see-more-reward.resolver.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Float, Parent, ResolveField, Resolver } from "@nestjs/graphql"; -import { Item } from "./item.object"; -import { ContentSeeMoreReward } from "./content-see-more-reward.object"; -import { DataLoaderService } from "src/dataloader/data-loader.service"; -import { UserContentService } from "src/user/service/user-content.service"; - -@Resolver(() => ContentSeeMoreReward) -export class ContentSeeMoreRewardResolver { - constructor( - private dataLoaderService: DataLoaderService, - private userContentService: UserContentService - ) {} - - @ResolveField(() => Item) - async item(@Parent() contentSeeMoreReward: ContentSeeMoreReward) { - return await this.dataLoaderService.item.findUniqueOrThrowById(contentSeeMoreReward.itemId); - } - - @ResolveField(() => Float) - async quantity(@Parent() contentSeeMoreReward: ContentSeeMoreReward) { - return await this.userContentService.getContentSeeMoreRewardQuantity(contentSeeMoreReward.id); - } -} diff --git a/src/backend/src/content/object/content-wage.object.ts b/src/backend/src/content/object/content-wage.object.ts deleted file mode 100644 index 949af346..00000000 --- a/src/backend/src/content/object/content-wage.object.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Field, Float, InputType, ObjectType } from "@nestjs/graphql"; - -@ObjectType() -export class ContentWage { - @Field() - contentId: number; - - @Field(() => Float) - goldAmountPerClear: number; - - @Field(() => Float) - goldAmountPerHour: number; - - @Field(() => Float) - krwAmountPerHour: number; -} - -@InputType() -export class ContentWageFilter { - @Field(() => Boolean, { nullable: true }) - includeIsBound?: boolean; - - @Field(() => Boolean, { nullable: true }) - includeIsSeeMore?: boolean; - - @Field(() => [Number], { nullable: true }) - includeItemIds?: number[]; -} diff --git a/src/backend/src/content/object/content-wage.resolver.ts b/src/backend/src/content/object/content-wage.resolver.ts deleted file mode 100644 index 8d8188c5..00000000 --- a/src/backend/src/content/object/content-wage.resolver.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Parent, ResolveField, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { ContentWage } from "./content-wage.object"; -import { Content } from "./content.object"; - -@Resolver(() => ContentWage) -export class ContentWageResolver { - constructor(private prisma: PrismaService) {} - - @ResolveField(() => Content) - async content(@Parent() contentWage: ContentWage) { - return await this.prisma.content.findUniqueOrThrow({ - where: { - id: contentWage.contentId, - }, - }); - } -} diff --git a/src/backend/src/content/object/content.object.ts b/src/backend/src/content/object/content.object.ts deleted file mode 100644 index 522b5bbc..00000000 --- a/src/backend/src/content/object/content.object.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { BaseObject } from "src/common/object/base.object"; - -@ObjectType() -export class ContentObjectWageFilter { - @Field(() => Boolean, { nullable: true }) - includeIsBound?: boolean; - - @Field(() => Boolean, { nullable: true }) - includeIsSeeMore?: boolean; - - @Field(() => [String], { nullable: true }) - includeItemIds?: string[]; -} - -@ObjectType() -export class Content extends BaseObject { - @Field() - contentCategoryId: number; - - @Field({ nullable: true }) - gate?: number; - - @Field() - level: number; - - @Field() - name: string; - - @Field(() => ContentObjectWageFilter, { nullable: true }) - wageFilter?: ContentObjectWageFilter; -} diff --git a/src/backend/src/content/object/content.resolver.ts b/src/backend/src/content/object/content.resolver.ts deleted file mode 100644 index 65801cea..00000000 --- a/src/backend/src/content/object/content.resolver.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Args, Parent, ResolveField, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { Content } from "./content.object"; -import { ContentReward } from "./content-reward.object"; -import { ContentCategory } from "./content-category.object"; -import { UserContentService } from "../../user/service/user-content.service"; -import { ContentSeeMoreReward } from "./content-see-more-reward.object"; -import { DataLoaderService } from "src/dataloader/data-loader.service"; -import { ContentWage, ContentWageFilter } from "./content-wage.object"; -import { ContentWageService } from "../service/content-wage.service"; - -@Resolver(() => Content) -export class ContentResolver { - constructor( - private prisma: PrismaService, - private userContentService: UserContentService, - private dataLoaderService: DataLoaderService, - private contentWageService: ContentWageService - ) {} - - @ResolveField(() => ContentCategory) - async contentCategory(@Parent() content: Content) { - return await this.dataLoaderService.contentCategory.findUniqueOrThrowById( - content.contentCategoryId - ); - } - - @ResolveField(() => [ContentReward]) - async contentRewards(@Parent() content: Content) { - return await this.dataLoaderService.contentRewards.findManyByContentId(content.id); - } - - @ResolveField(() => [ContentSeeMoreReward]) - async contentSeeMoreRewards(@Parent() content: Content) { - return await this.dataLoaderService.contentSeeMoreRewards.findManyByContentId(content.id); - } - - @ResolveField(() => String) - async displayName(@Parent() content: Content) { - const { gate, name } = content; - return `${name}${gate ? ` ${gate}관문` : ""}`; - } - - @ResolveField(() => Number) - async duration(@Parent() content: Content) { - return await this.userContentService.getContentDuration(content.id); - } - - @ResolveField(() => String) - async durationText(@Parent() content: Content) { - const durationInSeconds = await this.duration(content); - const minutes = Math.floor(durationInSeconds / 60); - const seconds = durationInSeconds % 60; - - return seconds === 0 ? `${minutes}분` : `${minutes}분 ${seconds}초`; - } - - @ResolveField(() => ContentWage) - async wage( - @Parent() content: Content, - @Args("filter", { nullable: true }) filter?: ContentWageFilter - ) { - return await this.contentWageService.getContentWage(content.id, filter); - } -} diff --git a/src/backend/src/content/object/item.object.ts b/src/backend/src/content/object/item.object.ts deleted file mode 100644 index 9e0bd5ed..00000000 --- a/src/backend/src/content/object/item.object.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { ItemKind } from "@prisma/client"; -import { BaseObject } from "src/common/object/base.object"; - -@ObjectType() -export class Item extends BaseObject { - @Field() - imageUrl: string; - - @Field(() => ItemKind) - kind: ItemKind; - - @Field() - name: string; -} diff --git a/src/backend/src/content/object/item.resolver.ts b/src/backend/src/content/object/item.resolver.ts deleted file mode 100644 index 686f573c..00000000 --- a/src/backend/src/content/object/item.resolver.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Parent, ResolveField, Resolver } from "@nestjs/graphql"; -import { Float } from "@nestjs/graphql"; -import { Item } from "./item.object"; -import { UserContentService } from "src/user/service/user-content.service"; -import { User } from "src/common/object/user.object"; -import { CurrentUser } from "src/common/decorator/current-user.decorator"; -import { PrismaService } from "src/prisma"; -import { UserItem } from "./user-item.object"; -import { UseGuards } from "@nestjs/common"; -import { AuthGuard } from "src/auth/auth.guard"; - -@Resolver(() => Item) -export class ItemResolver { - constructor( - private userContentService: UserContentService, - private prisma: PrismaService - ) {} - - @ResolveField(() => Float) - async price(@Parent() item: Item) { - return await this.userContentService.getItemPrice(item.id); - } - - @UseGuards(AuthGuard) - @ResolveField(() => UserItem) - async userItem(@Parent() item: Item, @CurrentUser() user: User) { - return await this.prisma.userItem.findUniqueOrThrow({ - where: { - userId_itemId: { - itemId: item.id, - userId: user.id, - }, - }, - }); - } -} diff --git a/src/backend/src/content/query/content-duration.query.ts b/src/backend/src/content/query/content-duration.query.ts deleted file mode 100644 index 5bc4b589..00000000 --- a/src/backend/src/content/query/content-duration.query.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Args, Query, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { ContentDuration } from "../object/content-duration.object"; - -@Resolver() -export class ContentDurationQuery { - constructor(private prisma: PrismaService) {} - - @Query(() => ContentDuration) - async contentDuration(@Args("id") id: number) { - return await this.prisma.contentDuration.findUniqueOrThrow({ - where: { - id, - }, - }); - } -} diff --git a/src/backend/src/content/query/content-group-wage-list.query.ts b/src/backend/src/content/query/content-group-wage-list.query.ts deleted file mode 100644 index 418528f1..00000000 --- a/src/backend/src/content/query/content-group-wage-list.query.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Args, Field, InputType, Query, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { ContentStatus, Prisma } from "@prisma/client"; -import { ContentWageService } from "../service/content-wage.service"; -import { ContentWageFilter } from "../object/content-wage.object"; -import { OrderByArg } from "src/common/object/order-by-arg.object"; -import _ from "lodash"; -import { ContentGroupWage } from "../object/content-group-wage.object"; - -@InputType() -export class ContentGroupWageListFilter extends ContentWageFilter { - @Field({ nullable: true }) - contentCategoryId?: number; - - @Field(() => String, { nullable: true }) - keyword?: string; - - @Field(() => ContentStatus, { nullable: true }) - status?: ContentStatus; -} - -@Resolver() -export class ContentGroupWageListQuery { - constructor( - private prisma: PrismaService, - private contentWageService: ContentWageService - ) {} - - buildWhereArgs(filter?: ContentGroupWageListFilter) { - const where: Prisma.ContentWhereInput = {}; - - if (filter?.contentCategoryId) { - where.contentCategoryId = filter.contentCategoryId; - } - - if (filter?.keyword) { - where.OR = [ - { - name: { - contains: filter.keyword, - mode: "insensitive", - }, - }, - { - contentCategory: { - name: { - contains: filter.keyword, - mode: "insensitive", - }, - }, - }, - ]; - } - - if (filter?.status) { - where.status = filter.status; - } - - return where; - } - - @Query(() => [ContentGroupWage]) - async contentGroupWageList( - @Args("filter", { nullable: true }) filter?: ContentGroupWageListFilter, - @Args("orderBy", { - nullable: true, - type: () => [OrderByArg], - }) - orderBy?: OrderByArg[] - ) { - const contents = await this.prisma.content.findMany({ - include: { - contentCategory: true, - contentSeeMoreRewards: { - include: { - item: true, - }, - }, - }, - orderBy: [ - { - contentCategory: { - id: "asc", - }, - }, - { - level: "asc", - }, - { - id: "asc", - }, - ], - where: this.buildWhereArgs(filter), - }); - - const contentGroups = _.groupBy( - contents, - (content) => `${content.name}_${content.contentCategoryId}` - ); - - const promises = Object.entries(contentGroups).map(async ([_, groupContents]) => { - const contentIds = groupContents.map((content) => content.id); - - const representative = groupContents[0]; - - const wage = await this.contentWageService.getContentGroupWage(contentIds, { - includeIsBound: filter?.includeIsBound, - includeIsSeeMore: filter?.includeIsSeeMore, - includeItemIds: filter?.includeItemIds, - }); - - return { - contentGroup: { - contentCategoryId: representative.contentCategoryId, - contentIds, - level: representative.level, - name: representative.name, - }, - ...wage, - }; - }); - - const result = orderBy - ? _.orderBy( - await Promise.all(promises), - orderBy.map((order) => order.field), - orderBy.map((order) => order.order) - ) - : await Promise.all(promises); - - return result; - } -} diff --git a/src/backend/src/content/query/content-group.query.ts b/src/backend/src/content/query/content-group.query.ts deleted file mode 100644 index 4a360639..00000000 --- a/src/backend/src/content/query/content-group.query.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Args, Field, InputType, Query, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { Prisma } from "@prisma/client"; -import { ContentGroup } from "../object/content-group.object"; - -@InputType() -export class ContentGroupFilter { - @Field(() => [Number], { nullable: true }) - contentIds?: number[]; -} - -@Resolver() -export class ContentGroupQuery { - constructor(private prisma: PrismaService) {} - - buildWhereArgs(filter?: ContentGroupFilter) { - const where: Prisma.ContentWhereInput = {}; - - if (filter?.contentIds) { - where.id = { - in: filter.contentIds, - }; - } - - return where; - } - - @Query(() => ContentGroup) - async contentGroup(@Args("filter", { nullable: true }) filter?: ContentGroupFilter) { - const contents = await this.prisma.content.findMany({ - orderBy: [ - { - contentCategory: { - id: "asc", - }, - }, - { - level: "asc", - }, - { - id: "asc", - }, - ], - where: this.buildWhereArgs(filter), - }); - - for (const content of contents) { - if (content.level !== contents[0].level) { - throw new Error("Content level is not the same"); - } - - if (content.contentCategoryId !== contents[0].contentCategoryId) { - throw new Error("Content category is not the same"); - } - } - - return { - contentIds: contents.map((content) => content.id), - level: contents[0].level, - name: contents[0].name, - }; - } -} diff --git a/src/backend/src/content/query/content-list.query.ts b/src/backend/src/content/query/content-list.query.ts deleted file mode 100644 index 7aa24f65..00000000 --- a/src/backend/src/content/query/content-list.query.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Args, Field, InputType, Query, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { Content } from "../object/content.object"; -import { ContentStatus, Prisma } from "@prisma/client"; -import _ from "lodash"; - -@InputType() -export class ContentListFilter { - @Field({ nullable: true }) - contentCategoryId?: number; - - @Field(() => Boolean, { nullable: true }) - includeIsSeeMore?: boolean; - - @Field(() => String, { nullable: true }) - keyword?: string; - - @Field(() => ContentStatus, { nullable: true }) - status?: ContentStatus; -} - -@Resolver() -export class ContentListQuery { - constructor(private prisma: PrismaService) {} - - buildWhereArgs(filter?: ContentListFilter) { - const where: Prisma.ContentWhereInput = {}; - - if (filter?.contentCategoryId) { - where.contentCategoryId = filter.contentCategoryId; - } - - if (filter?.keyword) { - where.OR = [ - { - name: { - contains: filter.keyword, - mode: "insensitive", - }, - }, - { - contentCategory: { - name: { - contains: filter.keyword, - mode: "insensitive", - }, - }, - }, - ]; - } - - if (filter?.status) { - where.status = filter.status; - } - - return where; - } - - @Query(() => [Content]) - async contentList(@Args("filter", { nullable: true }) filter?: ContentListFilter) { - return await this.prisma.content.findMany({ - orderBy: [ - { - contentCategory: { - id: "asc", - }, - }, - { - level: "asc", - }, - { - id: "asc", - }, - ], - where: this.buildWhereArgs(filter), - }); - } -} diff --git a/src/backend/src/content/query/content-wage-list.query.ts b/src/backend/src/content/query/content-wage-list.query.ts deleted file mode 100644 index ba7aef19..00000000 --- a/src/backend/src/content/query/content-wage-list.query.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Args, Field, InputType, Query, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { ContentStatus, Prisma } from "@prisma/client"; -import { ContentWageService } from "../service/content-wage.service"; -import { ContentWage, ContentWageFilter } from "../object/content-wage.object"; -import { OrderByArg } from "src/common/object/order-by-arg.object"; -import _ from "lodash"; - -@InputType() -export class ContentWageListFilter extends ContentWageFilter { - @Field({ nullable: true }) - contentCategoryId?: number; - - @Field(() => String, { nullable: true }) - keyword?: string; - - @Field(() => ContentStatus, { nullable: true }) - status?: ContentStatus; -} - -@Resolver() -export class ContentWageListQuery { - constructor( - private prisma: PrismaService, - private contentWageService: ContentWageService - ) {} - - buildWhereArgs(filter?: ContentWageListFilter) { - const where: Prisma.ContentWhereInput = {}; - - if (filter?.contentCategoryId) { - where.contentCategoryId = filter.contentCategoryId; - } - - if (filter?.keyword) { - where.OR = [ - { - name: { - contains: filter.keyword, - mode: "insensitive", - }, - }, - { - contentCategory: { - name: { - contains: filter.keyword, - mode: "insensitive", - }, - }, - }, - ]; - } - - if (filter?.status) { - where.status = filter.status; - } - - return where; - } - - @Query(() => [ContentWage]) - async contentWageList( - @Args("filter", { nullable: true }) filter?: ContentWageListFilter, - @Args("orderBy", { - nullable: true, - type: () => [OrderByArg], - }) - orderBy?: OrderByArg[] - ) { - const contents = await this.prisma.content.findMany({ - include: { - contentSeeMoreRewards: { - include: { - item: true, - }, - }, - }, - orderBy: [ - { - contentCategory: { - id: "asc", - }, - }, - { - level: "asc", - }, - { - id: "asc", - }, - ], - where: this.buildWhereArgs(filter), - }); - - const promises = contents.map(async (content) => { - return await this.contentWageService.getContentWage(content.id, { - includeIsBound: filter?.includeIsBound, - includeIsSeeMore: filter?.includeIsSeeMore, - includeItemIds: filter?.includeItemIds, - }); - }); - - const result = orderBy - ? _.orderBy( - await Promise.all(promises), - orderBy.map((order) => order.field), - orderBy.map((order) => order.order) - ) - : await Promise.all(promises); - - return result; - } -} diff --git a/src/backend/src/content/query/content.query.ts b/src/backend/src/content/query/content.query.ts deleted file mode 100644 index 6f9370c7..00000000 --- a/src/backend/src/content/query/content.query.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Args, Query, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { Content } from "../object/content.object"; - -@Resolver() -export class ContentQuery { - constructor(private prisma: PrismaService) {} - - @Query(() => Content) - async content(@Args("id") id: number) { - return await this.prisma.content.findUniqueOrThrow({ - where: { - id, - }, - }); - } -} diff --git a/src/backend/src/content/query/contents.query.ts b/src/backend/src/content/query/contents.query.ts deleted file mode 100644 index f6eea532..00000000 --- a/src/backend/src/content/query/contents.query.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Args, Field, InputType, Query, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { Content } from "../object/content.object"; -import { Prisma } from "@prisma/client"; -import _ from "lodash"; - -@InputType() -export class ContentsFilter { - @Field(() => [Number], { nullable: true }) - ids?: number[]; -} - -@Resolver() -export class ContentsQuery { - constructor(private prisma: PrismaService) {} - - buildWhereArgs(filter?: ContentsFilter) { - const where: Prisma.ContentWhereInput = {}; - - if (filter?.ids) { - where.id = { - in: filter.ids, - }; - } - - return where; - } - - @Query(() => [Content]) - async contents(@Args("filter", { nullable: true }) filter?: ContentsFilter) { - return await this.prisma.content.findMany({ - orderBy: [ - { - contentCategory: { - id: "asc", - }, - }, - { - level: "asc", - }, - { - id: "asc", - }, - ], - where: this.buildWhereArgs(filter), - }); - } -} diff --git a/src/backend/src/content/query/item.query.ts b/src/backend/src/content/query/item.query.ts deleted file mode 100644 index cf07c286..00000000 --- a/src/backend/src/content/query/item.query.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Args, Query, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { Item } from "../object/item.object"; - -@Resolver() -export class ItemQuery { - constructor(private prisma: PrismaService) {} - - @Query(() => Item) - async item(@Args("id") id: number) { - return await this.prisma.item.findUniqueOrThrow({ - where: { - id, - }, - }); - } -} diff --git a/src/backend/src/content/query/items.query.ts b/src/backend/src/content/query/items.query.ts deleted file mode 100644 index 3804c084..00000000 --- a/src/backend/src/content/query/items.query.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Args, Field, InputType, Query, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { Item } from "../object/item.object"; -import { ItemKind, Prisma } from "@prisma/client"; -import { ItemSortOrder } from "../constants"; - -@InputType() -class ItemsFilter { - @Field({ nullable: true }) - excludeItemName?: string; - - @Field(() => ItemKind, { nullable: true }) - kind?: ItemKind; -} - -@Resolver() -export class ItemsQuery { - constructor(private prisma: PrismaService) {} - - @Query(() => [Item]) - async items(@Args("filter", { nullable: true }) filter?: ItemsFilter) { - const items = await this.prisma.item.findMany({ - where: this.buildWhereArgs(filter), - }); - - return items.sort((a, b) => { - const aOrder = ItemSortOrder[a.name] || 999; - const bOrder = ItemSortOrder[b.name] || 999; - return aOrder - bOrder; - }); - } - - private buildWhereArgs(filter: ItemsFilter) { - const where: Prisma.ItemWhereInput = {}; - - if (filter?.kind) { - where.kind = filter.kind; - } - - if (filter?.excludeItemName) { - where.name = { - not: filter.excludeItemName, - }; - } - - return where; - } -} diff --git a/src/backend/src/content/reward/reward.dto.ts b/src/backend/src/content/reward/reward.dto.ts new file mode 100644 index 00000000..f347315b --- /dev/null +++ b/src/backend/src/content/reward/reward.dto.ts @@ -0,0 +1,73 @@ +import { Field, Float, InputType, ObjectType } from "@nestjs/graphql"; +import { ArrayMinSize, IsArray, IsBoolean, IsNumber, Min, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; +import { MutationResult } from "src/common/dto/mutation-result.dto"; + +@InputType() +export class EditContentRewardInput { + @Field(() => Float, { description: "평균 획득 수량" }) + @IsNumber() + @Min(0) + averageQuantity: number; + + @Field({ description: "컨텐츠 ID" }) + @IsNumber() + @Min(1) + contentId: number; + + @Field(() => Boolean, { description: "거래 가능 여부" }) + @IsBoolean() + isSellable: boolean; + + @Field({ description: "아이템 ID" }) + @IsNumber() + @Min(1) + itemId: number; +} + +@InputType() +export class EditContentRewardsInput { + @Field(() => [EditContentRewardInput], { + description: "수정할 컨텐츠 보상 목록", + }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => EditContentRewardInput) + contentRewards: EditContentRewardInput[]; + + @Field({ description: "제보 가능 여부" }) + @IsBoolean() + isReportable: boolean; +} + +@ObjectType() +export class EditContentRewardsResult extends MutationResult {} + +@InputType() +export class ReportContentRewardInput { + @Field(() => Float, { description: "평균 획득 수량" }) + @IsNumber() + @Min(0) + averageQuantity: number; + + @Field({ description: "컨텐츠 보상 ID" }) + @IsNumber() + @Min(1) + id: number; +} + +@InputType() +export class ReportContentRewardsInput { + @Field(() => [ReportContentRewardInput], { + description: "제보할 컨텐츠 보상 목록", + }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => ReportContentRewardInput) + contentRewards: ReportContentRewardInput[]; +} + +@ObjectType() +export class ReportContentRewardsResult extends MutationResult {} diff --git a/src/backend/src/content/object/user-content-reward.object.ts b/src/backend/src/content/reward/reward.object.ts similarity index 78% rename from src/backend/src/content/object/user-content-reward.object.ts rename to src/backend/src/content/reward/reward.object.ts index 05fcdcbc..fd270074 100644 --- a/src/backend/src/content/object/user-content-reward.object.ts +++ b/src/backend/src/content/reward/reward.object.ts @@ -1,6 +1,12 @@ import { Field, Float, ObjectType } from "@nestjs/graphql"; import { BaseObject } from "src/common/object/base.object"; +@ObjectType() +export class ContentReward extends BaseObject { + @Field() + itemId: number; +} + @ObjectType() export class UserContentReward extends BaseObject { @Field(() => Float) diff --git a/src/backend/src/content/reward/reward.resolver.ts b/src/backend/src/content/reward/reward.resolver.ts new file mode 100644 index 00000000..72a22576 --- /dev/null +++ b/src/backend/src/content/reward/reward.resolver.ts @@ -0,0 +1,62 @@ +import { UseGuards } from "@nestjs/common"; +import { Args, Float, Mutation, Parent, ResolveField, Resolver } from "@nestjs/graphql"; +import { User as PrismaUser } from "@prisma/client"; +import { AuthGuard } from "src/auth/auth.guard"; +import { CurrentUser } from "src/common/decorator/current-user.decorator"; +import { User } from "src/common/object/user.object"; +import { DataLoaderService } from "src/dataloader/data-loader.service"; +import { UserContentService } from "src/user/service/user-content.service"; +import { Item } from "../item/item.object"; +import { + EditContentRewardsInput, + EditContentRewardsResult, + ReportContentRewardsInput, + ReportContentRewardsResult, +} from "./reward.dto"; +import { RewardService } from "./reward.service"; +import { ContentReward } from "./reward.object"; + +@Resolver(() => ContentReward) +export class RewardResolver { + constructor( + private dataLoaderService: DataLoaderService, + private rewardService: RewardService, + private userContentService: UserContentService + ) {} + + @UseGuards(AuthGuard) + @Mutation(() => EditContentRewardsResult) + async contentRewardsEdit( + @Args("input") input: EditContentRewardsInput, + @CurrentUser() user: User + ) { + return await this.rewardService.editContentRewards(input, user.id, user.role); + } + + @UseGuards(AuthGuard) + @Mutation(() => ReportContentRewardsResult) + async contentRewardsReport( + @Args("input") input: ReportContentRewardsInput, + @CurrentUser() user: User + ) { + return await this.rewardService.reportContentRewards(input, user.id); + } + + @ResolveField(() => Float) + async averageQuantity(@Parent() contentReward: ContentReward, @CurrentUser() user?: PrismaUser) { + return await this.userContentService.getContentRewardAverageQuantity( + contentReward.id, + user?.id + ); + } + + @ResolveField(() => Boolean) + async isSellable(@Parent() contentReward: ContentReward, @CurrentUser() user?: PrismaUser) { + return await this.userContentService.getContentRewardIsSellable(contentReward.id, user?.id); + } + + @ResolveField(() => Item) + async item(@Parent() contentReward: ContentReward) { + return await this.dataLoaderService.item.findUniqueOrThrowById(contentReward.itemId); + } +} diff --git a/src/backend/src/content/reward/reward.service.ts b/src/backend/src/content/reward/reward.service.ts new file mode 100644 index 00000000..4f752dc0 --- /dev/null +++ b/src/backend/src/content/reward/reward.service.ts @@ -0,0 +1,125 @@ +import { Injectable } from "@nestjs/common"; +import { Prisma, UserRole } from "@prisma/client"; +import { PrismaService } from "src/prisma"; +import { + EditContentRewardsInput, + EditContentRewardsResult, + ReportContentRewardsInput, + ReportContentRewardsResult, +} from "./reward.dto"; + +type TransactionClient = Prisma.TransactionClient; + +@Injectable() +export class RewardService { + constructor(private prisma: PrismaService) {} + + async editContentRewards( + input: EditContentRewardsInput, + userId: number, + userRole: UserRole + ): Promise { + return await this.prisma.$transaction(async (tx) => { + if (userRole === UserRole.OWNER) { + await this.updateOwnerRewards(tx, input); + } + + await this.upsertUserRewards(tx, input, userId); + + if (input.isReportable) { + await this.createRewardReports(tx, input, userId); + } + + return { ok: true }; + }); + } + + async reportContentRewards( + input: ReportContentRewardsInput, + userId: number + ): Promise { + return await this.prisma.$transaction(async (tx) => { + await Promise.all( + input.contentRewards.map(async ({ averageQuantity, id }) => { + return tx.reportedContentReward.create({ + data: { + averageQuantity, + contentRewardId: id, + userId, + }, + }); + }) + ); + + return { ok: true }; + }); + } + + private async createRewardReports( + tx: TransactionClient, + input: EditContentRewardsInput, + userId: number + ): Promise { + await Promise.all( + input.contentRewards.map(async ({ averageQuantity, contentId, itemId }) => { + const contentReward = await tx.contentReward.findUniqueOrThrow({ + where: { contentId_itemId: { contentId, itemId } }, + }); + + return tx.reportedContentReward.create({ + data: { + averageQuantity, + contentRewardId: contentReward.id, + userId, + }, + }); + }) + ); + } + + private async updateOwnerRewards( + tx: TransactionClient, + input: EditContentRewardsInput + ): Promise { + await Promise.all( + input.contentRewards.map(async ({ averageQuantity, contentId, isSellable, itemId }) => { + await tx.contentReward.update({ + data: { + averageQuantity, + isSellable, + }, + where: { + contentId_itemId: { + contentId, + itemId, + }, + }, + }); + }) + ); + } + + private async upsertUserRewards( + tx: TransactionClient, + input: EditContentRewardsInput, + userId: number + ): Promise { + await Promise.all( + input.contentRewards.map(({ averageQuantity, contentId, isSellable, itemId }) => + tx.userContentReward.upsert({ + create: { + averageQuantity, + contentId, + isSellable, + itemId, + userId, + }, + update: { averageQuantity, isSellable }, + where: { + userId_contentId_itemId: { contentId, itemId, userId }, + }, + }) + ) + ); + } +} diff --git a/src/backend/src/content/see-more-reward/see-more-reward.dto.ts b/src/backend/src/content/see-more-reward/see-more-reward.dto.ts new file mode 100644 index 00000000..ade6c79a --- /dev/null +++ b/src/backend/src/content/see-more-reward/see-more-reward.dto.ts @@ -0,0 +1,37 @@ +import { Field, Float, InputType, ObjectType } from "@nestjs/graphql"; +import { ArrayMinSize, IsArray, IsNumber, Min, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; +import { MutationResult } from "src/common/dto/mutation-result.dto"; + +@InputType() +export class EditContentSeeMoreRewardInput { + @Field({ description: "컨텐츠 ID" }) + @IsNumber() + @Min(1) + contentId: number; + + @Field({ description: "아이템 ID" }) + @IsNumber() + @Min(1) + itemId: number; + + @Field(() => Float, { description: "획득 수량" }) + @IsNumber() + @Min(0) + quantity: number; +} + +@InputType() +export class EditContentSeeMoreRewardsInput { + @Field(() => [EditContentSeeMoreRewardInput], { + description: "수정할 추가 보상(더보기) 목록", + }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => EditContentSeeMoreRewardInput) + contentSeeMoreRewards: EditContentSeeMoreRewardInput[]; +} + +@ObjectType() +export class EditContentSeeMoreRewardsResult extends MutationResult {} diff --git a/src/backend/src/content/object/user-content-see-more-reward.object.ts b/src/backend/src/content/see-more-reward/see-more-reward.object.ts similarity index 63% rename from src/backend/src/content/object/user-content-see-more-reward.object.ts rename to src/backend/src/content/see-more-reward/see-more-reward.object.ts index 49252a1f..b9567331 100644 --- a/src/backend/src/content/object/user-content-see-more-reward.object.ts +++ b/src/backend/src/content/see-more-reward/see-more-reward.object.ts @@ -1,6 +1,15 @@ import { Field, Float, ObjectType } from "@nestjs/graphql"; import { BaseObject } from "src/common/object/base.object"; +@ObjectType() +export class ContentSeeMoreReward extends BaseObject { + @Field() + contentId: number; + + @Field() + itemId: number; +} + @ObjectType() export class UserContentSeeMoreReward extends BaseObject { @Field(() => Float) diff --git a/src/backend/src/content/see-more-reward/see-more-reward.resolver.ts b/src/backend/src/content/see-more-reward/see-more-reward.resolver.ts new file mode 100644 index 00000000..ff3efb2a --- /dev/null +++ b/src/backend/src/content/see-more-reward/see-more-reward.resolver.ts @@ -0,0 +1,49 @@ +import { UseGuards } from "@nestjs/common"; +import { Args, Float, Mutation, Parent, ResolveField, Resolver } from "@nestjs/graphql"; +import { User as PrismaUser } from "@prisma/client"; +import { AuthGuard } from "src/auth/auth.guard"; +import { CurrentUser } from "src/common/decorator/current-user.decorator"; +import { User } from "src/common/object/user.object"; +import { DataLoaderService } from "src/dataloader/data-loader.service"; +import { UserContentService } from "src/user/service/user-content.service"; +import { Item } from "../item/item.object"; +import { + EditContentSeeMoreRewardsInput, + EditContentSeeMoreRewardsResult, +} from "./see-more-reward.dto"; +import { SeeMoreRewardService } from "./see-more-reward.service"; +import { ContentSeeMoreReward } from "./see-more-reward.object"; + +@Resolver(() => ContentSeeMoreReward) +export class SeeMoreRewardResolver { + constructor( + private dataLoaderService: DataLoaderService, + private seeMoreRewardService: SeeMoreRewardService, + private userContentService: UserContentService + ) {} + + @UseGuards(AuthGuard) + @Mutation(() => EditContentSeeMoreRewardsResult) + async contentSeeMoreRewardsEdit( + @Args("input") input: EditContentSeeMoreRewardsInput, + @CurrentUser() user: User + ) { + return await this.seeMoreRewardService.editContentSeeMoreRewards(input, user.id, user.role); + } + + @ResolveField(() => Item) + async item(@Parent() contentSeeMoreReward: ContentSeeMoreReward) { + return await this.dataLoaderService.item.findUniqueOrThrowById(contentSeeMoreReward.itemId); + } + + @ResolveField(() => Float) + async quantity( + @Parent() contentSeeMoreReward: ContentSeeMoreReward, + @CurrentUser() user?: PrismaUser + ) { + return await this.userContentService.getContentSeeMoreRewardQuantity( + contentSeeMoreReward.id, + user?.id + ); + } +} diff --git a/src/backend/src/content/see-more-reward/see-more-reward.service.ts b/src/backend/src/content/see-more-reward/see-more-reward.service.ts new file mode 100644 index 00000000..3bc51262 --- /dev/null +++ b/src/backend/src/content/see-more-reward/see-more-reward.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from "@nestjs/common"; +import { Prisma, UserRole } from "@prisma/client"; +import { PrismaService } from "src/prisma"; +import { + EditContentSeeMoreRewardsInput, + EditContentSeeMoreRewardsResult, +} from "./see-more-reward.dto"; + +type TransactionClient = Prisma.TransactionClient; + +@Injectable() +export class SeeMoreRewardService { + constructor(private prisma: PrismaService) {} + + async editContentSeeMoreRewards( + input: EditContentSeeMoreRewardsInput, + userId: number, + userRole: UserRole + ): Promise { + return await this.prisma.$transaction(async (tx) => { + if (userRole === UserRole.OWNER) { + await this.updateOwnerSeeMoreRewards(tx, input); + } + + await this.upsertUserSeeMoreRewards(tx, input, userId); + + return { ok: true }; + }); + } + + private async updateOwnerSeeMoreRewards( + tx: TransactionClient, + input: EditContentSeeMoreRewardsInput + ): Promise { + await Promise.all( + input.contentSeeMoreRewards.map(async ({ contentId, itemId, quantity }) => { + await tx.contentSeeMoreReward.update({ + data: { + quantity, + }, + where: { + contentId_itemId: { contentId, itemId }, + }, + }); + }) + ); + } + + private async upsertUserSeeMoreRewards( + tx: TransactionClient, + input: EditContentSeeMoreRewardsInput, + userId: number + ): Promise { + await Promise.all( + input.contentSeeMoreRewards.map(({ contentId, itemId, quantity }) => + tx.userContentSeeMoreReward.upsert({ + create: { contentId, itemId, quantity, userId }, + update: { quantity }, + where: { + userId_contentId_itemId: { contentId, itemId, userId }, + }, + }) + ) + ); + } +} diff --git a/src/backend/src/content/service/content-wage.service.ts b/src/backend/src/content/service/content-wage.service.ts deleted file mode 100644 index 71946e82..00000000 --- a/src/backend/src/content/service/content-wage.service.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { UserContentService } from "../../user/service/user-content.service"; -import { UserGoldExchangeRateService } from "src/user/service/user-gold-exchange-rate.service"; -import { PrismaService } from "src/prisma"; - -type Reward = { - averageQuantity: number; - itemId: number; -}; - -@Injectable() -export class ContentWageService { - constructor( - private userContentService: UserContentService, - private userGoldExchangeRateService: UserGoldExchangeRateService, - private prisma: PrismaService - ) {} - - async calculateGold(rewards: Reward[]) { - let gold = 0; - - for (const reward of rewards) { - const price = await this.userContentService.getItemPrice(reward.itemId); - - const averageQuantity = reward.averageQuantity; - gold += price * averageQuantity; - } - - return gold; - } - - async calculateSeeMoreRewardsGold( - contentSeeMoreRewards: { - itemId: number; - quantity: number; - }[], - includeItemIds?: number[] - ) { - const seeMoreRewards = contentSeeMoreRewards - .filter((reward) => { - if (includeItemIds && !includeItemIds.includes(reward.itemId)) { - return false; - } - return true; - }) - .map((reward) => ({ - averageQuantity: reward.quantity, - itemId: reward.itemId, - })); - - return await this.calculateGold(seeMoreRewards); - } - - async calculateWage({ duration, gold }: { duration: number; gold: number }) { - const goldExchangeRate = await this.userGoldExchangeRateService.getGoldExchangeRate(); - - const totalKRW = (gold * goldExchangeRate.krwAmount) / goldExchangeRate.goldAmount; - - const hours = duration / 3600; - const hourlyWage = totalKRW / hours; - const hourlyGold = gold / hours; - - return { - goldAmountPerHour: Math.round(hourlyGold), - krwAmountPerHour: Math.round(hourlyWage), - }; - } - - async getContentGroupWage( - contentIds: number[], - filter: { - includeIsBound?: boolean; - includeIsSeeMore?: boolean; - includeItemIds?: number[]; - } - ) { - let totalGold = 0; - let totalDuration = 0; - - for (const contentId of contentIds) { - const content = await this.prisma.content.findUniqueOrThrow({ - where: { id: contentId }, - }); - - const rewards = await this.userContentService.getContentRewards(content.id, { - includeIsBound: filter?.includeIsBound, - includeItemIds: filter?.includeItemIds, - }); - - const seeMoreRewards = await this.userContentService.getContentSeeMoreRewards(content.id, { - includeItemIds: filter?.includeItemIds, - }); - - const rewardsGold = await this.calculateGold(rewards); - - const shouldIncludeSeeMoreRewards = - filter?.includeIsSeeMore && filter?.includeIsBound !== false && seeMoreRewards.length > 0; - - const seeMoreGold = shouldIncludeSeeMoreRewards - ? await this.calculateSeeMoreRewardsGold(seeMoreRewards, filter.includeItemIds) - : 0; - - const gold = rewardsGold + seeMoreGold; - totalGold += gold; - - const duration = await this.userContentService.getContentDuration(content.id); - totalDuration += duration; - } - - const { goldAmountPerHour, krwAmountPerHour } = await this.calculateWage({ - duration: totalDuration, - gold: totalGold, - }); - - return { - goldAmountPerClear: Math.round(totalGold), - goldAmountPerHour, - krwAmountPerHour, - }; - } - - // TODO: test - async getContentWage( - contentId: number, - filter: { - includeIsBound?: boolean; - includeIsSeeMore?: boolean; - includeItemIds?: number[]; - } - ) { - const content = await this.prisma.content.findUniqueOrThrow({ - where: { id: contentId }, - }); - - const rewards = await this.userContentService.getContentRewards(content.id, { - includeIsBound: filter?.includeIsBound, - includeItemIds: filter?.includeItemIds, - }); - - const seeMoreRewards = await this.userContentService.getContentSeeMoreRewards(content.id, { - includeItemIds: filter?.includeItemIds, - }); - - const rewardsGold = await this.calculateGold(rewards); - - const shouldIncludeSeeMoreRewards = - filter?.includeIsSeeMore && filter?.includeIsBound !== false && seeMoreRewards.length > 0; - - const seeMoreGold = shouldIncludeSeeMoreRewards - ? await this.calculateSeeMoreRewardsGold(seeMoreRewards, filter.includeItemIds) - : 0; - - const gold = rewardsGold + seeMoreGold; - - const duration = await this.userContentService.getContentDuration(content.id); - - const { goldAmountPerHour, krwAmountPerHour } = await this.calculateWage({ - duration, - gold, - }); - - return { - contentId: content.id, - goldAmountPerClear: Math.round(gold), - goldAmountPerHour, - krwAmountPerHour, - }; - } -} diff --git a/src/backend/src/content/constants.ts b/src/backend/src/content/shared/constants.ts similarity index 58% rename from src/backend/src/content/constants.ts rename to src/backend/src/content/shared/constants.ts index ec1e33fb..3ea6123b 100644 --- a/src/backend/src/content/constants.ts +++ b/src/backend/src/content/shared/constants.ts @@ -1,3 +1,17 @@ +import { Prisma } from "@prisma/client"; + +/** + * Content 기본 정렬 순서 + * - 카테고리 ID 오름차순 + * - 레벨 오름차순 + * - ID 오름차순 + */ +export const DEFAULT_CONTENT_ORDER_BY: Prisma.ContentOrderByWithRelationInput[] = [ + { contentCategory: { id: "asc" } }, + { level: "asc" }, + { id: "asc" }, +]; + // 추후 유지보수성이 확실치 않은 데이터 구조 및 순서라 db에서 관리하지 않고 임시로 상수로 보상 아이템 순서를 관리함. export const ItemSortOrder = { "1레벨 보석": 7, diff --git a/src/backend/src/content/content.controller.ts b/src/backend/src/content/shared/content.controller.ts similarity index 99% rename from src/backend/src/content/content.controller.ts rename to src/backend/src/content/shared/content.controller.ts index 95c5f412..4cb8e3b6 100644 --- a/src/backend/src/content/content.controller.ts +++ b/src/backend/src/content/shared/content.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Query } from "@nestjs/common"; -import { PrismaService } from "../prisma/prisma.service"; +import { PrismaService } from "src/prisma"; // TODO: 생각보다 본격적으로 사용되는 기능이라 유지보수 가능한 형태(테스트가 쉬운 형태)로 리팩토링 필요 @Controller("api/content") diff --git a/src/backend/src/content/wage/wage.dto.spec.ts b/src/backend/src/content/wage/wage.dto.spec.ts new file mode 100644 index 00000000..36eba8cb --- /dev/null +++ b/src/backend/src/content/wage/wage.dto.spec.ts @@ -0,0 +1,86 @@ +import { validate } from "class-validator"; +import { plainToInstance } from "class-transformer"; +import { CalculateCustomContentWageInput } from "./wage.dto"; + +describe("CalculateCustomContentWageInput Validation", () => { + const validInput = { + items: [ + { + id: 1, + quantity: 10.5, + }, + ], + minutes: 5, + seconds: 30, + }; + + it("유효한 입력을 허용해야 함", async () => { + const input = plainToInstance(CalculateCustomContentWageInput, validInput); + + const errors = await validate(input); + expect(errors).toHaveLength(0); + }); + + it("minutes가 59를 초과하면 에러", async () => { + const input = plainToInstance(CalculateCustomContentWageInput, { + ...validInput, + minutes: 60, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("minutes"); + }); + + it("seconds가 59를 초과하면 에러", async () => { + const input = plainToInstance(CalculateCustomContentWageInput, { + ...validInput, + seconds: 60, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("seconds"); + }); + + it("items가 빈 배열이면 에러", async () => { + const input = plainToInstance(CalculateCustomContentWageInput, { + ...validInput, + items: [], + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("items"); + }); + + it("중첩된 item 검증이 작동해야 함 (itemId < 1)", async () => { + const input = plainToInstance(CalculateCustomContentWageInput, { + ...validInput, + items: [ + { + id: 0, + quantity: 10, + }, + ], + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + }); + + it("중첩된 item 검증이 작동해야 함 (quantity < 0)", async () => { + const input = plainToInstance(CalculateCustomContentWageInput, { + ...validInput, + items: [ + { + id: 1, + quantity: -1, + }, + ], + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + }); +}); diff --git a/src/backend/src/content/wage/wage.dto.ts b/src/backend/src/content/wage/wage.dto.ts new file mode 100644 index 00000000..059adb76 --- /dev/null +++ b/src/backend/src/content/wage/wage.dto.ts @@ -0,0 +1,118 @@ +import { Field, Float, InputType, Int, ObjectType } from "@nestjs/graphql"; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsEnum, + IsNumber, + IsOptional, + IsString, + Max, + MaxLength, + Min, + ValidateNested, +} from "class-validator"; +import { ContentStatus } from "@prisma/client"; +import { Type } from "class-transformer"; +import { CONTENT_NAME_MAX_LENGTH } from "src/common/constants/content.constants"; +import { MutationResult } from "src/common/dto/mutation-result.dto"; + +@InputType() +export class ContentWageFilter { + @Field(() => Boolean, { + description: "귀속 아이템 포함 여부", + nullable: true, + }) + @IsOptional() + @IsBoolean() + includeBound?: boolean; + + @Field(() => [Number], { + description: "포함할 아이템 ID 목록", + nullable: true, + }) + @IsOptional() + @IsArray() + @IsNumber({}, { each: true }) + @Min(1, { each: true }) + includeItemIds?: number[]; + + @Field(() => Boolean, { + description: "추가 보상(더보기) 포함 여부", + nullable: true, + }) + @IsOptional() + @IsBoolean() + includeSeeMore?: boolean; +} + +@InputType() +export class ContentWageListFilter extends ContentWageFilter { + @Field({ + description: "필터링할 컨텐츠 카테고리 ID", + nullable: true, + }) + @IsOptional() + @IsNumber() + contentCategoryId?: number; + + @Field(() => String, { description: "검색 키워드", nullable: true }) + @IsOptional() + @IsString() + @MaxLength(CONTENT_NAME_MAX_LENGTH) + keyword?: string; + + @Field(() => ContentStatus, { description: "컨텐츠 상태", nullable: true }) + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; +} + +@InputType() +export class CalculateCustomContentWageInput { + @Field(() => [CalculateCustomContentWageItemInput], { + description: "아이템 목록", + }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CalculateCustomContentWageItemInput) + items: CalculateCustomContentWageItemInput[]; + + @Field(() => Int, { description: "분" }) + @IsNumber() + @Min(0) + @Max(59) + minutes: number; + + @Field(() => Int, { description: "초" }) + @IsNumber() + @Min(0) + @Max(59) + seconds: number; +} + +@InputType() +export class CalculateCustomContentWageItemInput { + @Field({ description: "아이템 ID" }) + @IsNumber() + @Min(1) + id: number; + + @Field(() => Float, { description: "획득 수량" }) + @IsNumber() + @Min(0) + quantity: number; +} + +@ObjectType() +export class CalculateCustomContentWageResult extends MutationResult { + @Field({ description: "회당 골드 획득량" }) + goldAmountPerClear: number; + + @Field({ description: "시급 (골드)" }) + goldAmountPerHour: number; + + @Field({ description: "시급 (원화)" }) + krwAmountPerHour: number; +} diff --git a/src/backend/src/content/wage/wage.object.ts b/src/backend/src/content/wage/wage.object.ts new file mode 100644 index 00000000..641c9e19 --- /dev/null +++ b/src/backend/src/content/wage/wage.object.ts @@ -0,0 +1,16 @@ +import { Field, Float, ObjectType } from "@nestjs/graphql"; + +@ObjectType() +export class ContentWage { + @Field() + contentId: number; + + @Field(() => Float) + goldAmountPerClear: number; + + @Field(() => Float) + goldAmountPerHour: number; + + @Field(() => Float) + krwAmountPerHour: number; +} diff --git a/src/backend/src/content/query/content-wage-list.query.e2e-spec.ts b/src/backend/src/content/wage/wage.resolver.e2e-spec.ts similarity index 97% rename from src/backend/src/content/query/content-wage-list.query.e2e-spec.ts rename to src/backend/src/content/wage/wage.resolver.e2e-spec.ts index a311bd30..310c5d87 100644 --- a/src/backend/src/content/query/content-wage-list.query.e2e-spec.ts +++ b/src/backend/src/content/wage/wage.resolver.e2e-spec.ts @@ -319,7 +319,7 @@ describe("ContentWageListQuery (e2e)", () => { expect(response.body.data.contentWageList[0].goldAmountPerClear).toBe(500); // item1만 계산 (100 * 5) }); - it("includeIsSeeMore 필터", async () => { + it("includeSeeMore 필터", async () => { // 콘텐츠 생성 const content = await contentFactory.create(); @@ -358,14 +358,14 @@ describe("ContentWageListQuery (e2e)", () => { }, }); - // includeIsSeeMore = true로 쿼리 + // includeSeeMore = true로 쿼리 const responseWithSeeMore = await request(app.getHttpServer()) .post("/graphql") .set("Content-Type", "application/json") .send({ query: ` query { - contentWageList(filter: { includeIsSeeMore: true }) { + contentWageList(filter: { includeSeeMore: true }) { contentId goldAmountPerClear } @@ -373,14 +373,14 @@ describe("ContentWageListQuery (e2e)", () => { `, }); - // includeIsSeeMore = false로 쿼리 + // includeSeeMore = false로 쿼리 const responseWithoutSeeMore = await request(app.getHttpServer()) .post("/graphql") .set("Content-Type", "application/json") .send({ query: ` query { - contentWageList(filter: { includeIsSeeMore: false }) { + contentWageList(filter: { includeSeeMore: false }) { contentId goldAmountPerClear } @@ -401,7 +401,7 @@ describe("ContentWageListQuery (e2e)", () => { expect(goldWithoutSeeMore).toBe(500); // 일반 500만 }); - it("includeIsBound 필터", async () => { + it("includeBound 필터", async () => { // 콘텐츠 생성 const content = await contentFactory.create(); @@ -448,14 +448,14 @@ describe("ContentWageListQuery (e2e)", () => { }, }); - // includeIsBound = false로 쿼리 (거래 가능 아이템만) + // includeBound = false로 쿼리 (거래 가능 아이템만) const response = await request(app.getHttpServer()) .post("/graphql") .set("Content-Type", "application/json") .send({ query: ` query { - contentWageList(filter: { includeIsBound: false }) { + contentWageList(filter: { includeBound: false }) { contentId goldAmountPerClear } @@ -649,7 +649,7 @@ describe("ContentWageListQuery (e2e)", () => { filter: { contentCategoryId: ${category1.id}, keyword: "특수", - includeIsBound: false, + includeBound: false, includeItemIds: [${item1.id}] } ) { diff --git a/src/backend/src/content/wage/wage.resolver.ts b/src/backend/src/content/wage/wage.resolver.ts new file mode 100644 index 00000000..24906057 --- /dev/null +++ b/src/backend/src/content/wage/wage.resolver.ts @@ -0,0 +1,155 @@ +import { Args, Mutation, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; +import { Prisma, User } from "@prisma/client"; +import { orderBy } from "es-toolkit/compat"; +import { CurrentUser } from "src/common/decorator/current-user.decorator"; +import { OrderByArg } from "src/common/object/order-by-arg.object"; +import { PrismaService } from "src/prisma"; +import { Content } from "../content/content.object"; +import { ContentDurationService } from "../duration/duration.service"; +import { ContentWageService } from "./wage.service"; +import { + CalculateCustomContentWageInput, + CalculateCustomContentWageResult, + ContentWageListFilter, +} from "./wage.dto"; +import { ContentWage } from "./wage.object"; + +@Resolver(() => ContentWage) +export class WageResolver { + constructor( + private prisma: PrismaService, + private contentWageService: ContentWageService, + private contentDurationService: ContentDurationService + ) {} + + @Query(() => [ContentWage]) + async contentWageList( + @Args("filter", { nullable: true }) filter?: ContentWageListFilter, + @Args("orderBy", { + nullable: true, + type: () => [OrderByArg], + }) + orderByArgs?: OrderByArg[], + @CurrentUser() user?: User + ) { + const contents = await this.prisma.content.findMany({ + include: { + contentSeeMoreRewards: { + include: { + item: true, + }, + }, + }, + orderBy: [ + { + contentCategory: { + id: "asc", + }, + }, + { + level: "asc", + }, + { + id: "asc", + }, + ], + where: this.buildContentWageListWhereArgs(filter), + }); + + const promises = contents.map(async (content) => { + return await this.contentWageService.getContentWage(content.id, user?.id, { + includeBound: filter?.includeBound, + includeItemIds: filter?.includeItemIds, + includeSeeMore: filter?.includeSeeMore, + }); + }); + + const results = await Promise.all(promises); + + return orderByArgs + ? orderBy( + results, + orderByArgs.map((o) => o.field), + orderByArgs.map((o) => o.order) + ) + : results; + } + + @Mutation(() => CalculateCustomContentWageResult) + async customContentWageCalculate( + @Args("input") input: CalculateCustomContentWageInput, + @CurrentUser() user?: User + ) { + const { items, minutes, seconds } = input; + + const totalSeconds = this.contentDurationService.getValidatedTotalSeconds({ + minutes, + seconds, + }); + + const rewardsGold = await this.contentWageService.calculateGold( + items.map((item) => ({ + averageQuantity: item.quantity, + itemId: item.id, + })), + user?.id + ); + + const { goldAmountPerHour, krwAmountPerHour } = await this.contentWageService.calculateWage( + { + duration: totalSeconds, + gold: rewardsGold, + }, + user?.id + ); + + return { + goldAmountPerClear: Math.round(rewardsGold), + goldAmountPerHour, + krwAmountPerHour, + ok: true, + }; + } + + @ResolveField(() => Content) + async content(@Parent() contentWage: ContentWage) { + return await this.prisma.content.findUniqueOrThrow({ + where: { + id: contentWage.contentId, + }, + }); + } + + buildContentWageListWhereArgs(filter?: ContentWageListFilter) { + const where: Prisma.ContentWhereInput = {}; + + if (filter?.contentCategoryId) { + where.contentCategoryId = filter.contentCategoryId; + } + + if (filter?.keyword) { + where.OR = [ + { + name: { + contains: filter.keyword, + mode: "insensitive", + }, + }, + { + contentCategory: { + name: { + contains: filter.keyword, + mode: "insensitive", + }, + }, + }, + ]; + } + + if (filter?.status) { + where.status = filter.status; + } + + return where; + } +} diff --git a/src/backend/src/content/service/content-wage.service.spec.ts b/src/backend/src/content/wage/wage.service.spec.ts similarity index 83% rename from src/backend/src/content/service/content-wage.service.spec.ts rename to src/backend/src/content/wage/wage.service.spec.ts index 3662427f..12c2c51f 100644 --- a/src/backend/src/content/service/content-wage.service.spec.ts +++ b/src/backend/src/content/wage/wage.service.spec.ts @@ -1,8 +1,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { PrismaService } from "src/prisma"; -import { ContentWageService } from "./content-wage.service"; +import { ContentWageService } from "./wage.service"; import { UserContentService } from "../../user/service/user-content.service"; -import { CONTEXT } from "@nestjs/graphql"; import { UserGoldExchangeRateService } from "src/user/service/user-gold-exchange-rate.service"; import { UserFactory } from "src/test/factory/user.factory"; import { faker } from "@faker-js/faker/."; @@ -15,8 +14,6 @@ describe("ContentWageService", () => { let prisma: PrismaService; let service: ContentWageService; let userFactory: UserFactory; - let userGoldExchangeRateService: UserGoldExchangeRateService; - let userContentService: UserContentService; let itemFactory: ItemFactory; beforeAll(async () => { @@ -28,18 +25,12 @@ describe("ContentWageService", () => { UserGoldExchangeRateService, UserFactory, ItemFactory, - { - provide: CONTEXT, - useValue: { req: { user: { id: undefined } } }, - }, ], }).compile(); prisma = module.get(PrismaService); service = module.get(ContentWageService); userFactory = module.get(UserFactory); - userGoldExchangeRateService = module.get(UserGoldExchangeRateService); - userContentService = module.get(UserContentService); itemFactory = module.get(ItemFactory); }); @@ -61,7 +52,7 @@ describe("ContentWageService", () => { }); it("기본 계산(실제 환율 사용)", async () => { - const result = await service.calculateWage({ duration, gold }); + const result = await service.calculateWage({ duration, gold }, undefined); expect(result).toEqual({ goldAmountPerHour: 6000, @@ -79,10 +70,7 @@ describe("ContentWageService", () => { userId: user.id, }, }); - - userGoldExchangeRateService["context"].req.user = { id: user.id }; - - const result = await service.calculateWage({ duration, gold }); + const result = await service.calculateWage({ duration, gold }, user.id); expect(result).toEqual({ goldAmountPerHour: 6000, @@ -92,11 +80,6 @@ describe("ContentWageService", () => { }); describe("calculateGold", () => { - beforeEach(() => { - userContentService["context"].req.user = { id: undefined }; - userGoldExchangeRateService["context"].req.user = { id: undefined }; - }); - it("기본 계산", async () => { const items = await Promise.all([ itemFactory.create({ @@ -118,14 +101,12 @@ describe("ContentWageService", () => { }, ]; - const result = await service.calculateGold(rewards); + const result = await service.calculateGold(rewards, undefined); expect(result).toBe(800); }); it("사용자 정의 가격 사용", async () => { const user = await userFactory.create(); - userContentService["context"].req.user = { id: user.id }; - const items = await Promise.all([ itemFactory.create({ data: { @@ -170,14 +151,12 @@ describe("ContentWageService", () => { }, ]; - const result = await service.calculateGold(rewards); + const result = await service.calculateGold(rewards, user.id); expect(result).toBe(2500); }); it("일부 아이템만 사용자 정의 가격 사용", async () => { const user = await userFactory.create(); - userContentService["context"].req.user = { id: user.id }; - const items = await Promise.all([ itemFactory.create({ data: { @@ -213,14 +192,12 @@ describe("ContentWageService", () => { }, ]; - const result = await service.calculateGold(rewards); + const result = await service.calculateGold(rewards, user.id); expect(result).toBe(1600); }); it("존재하지 않는 사용자 정의 가격은 기본 가격 사용", async () => { const user = await userFactory.create(); - userContentService["context"].req.user = { id: user.id }; - const items = await Promise.all([ itemFactory.create({ data: { @@ -257,7 +234,7 @@ describe("ContentWageService", () => { ]; try { - const result = await service.calculateGold(rewards); + const result = await service.calculateGold(rewards, undefined); expect(result).toBe(1600); } catch (error) { expect(error).toBeDefined(); @@ -266,7 +243,7 @@ describe("ContentWageService", () => { it("빈 배열이 입력된 경우", async () => { const rewards = []; - const result = await service.calculateGold(rewards); + const result = await service.calculateGold(rewards, undefined); expect(result).toBe(0); }); @@ -298,9 +275,6 @@ describe("ContentWageService", () => { userId: user2.id, }, }); - - userContentService["context"].req.user = { id: user1.id }; - const rewards = [ { averageQuantity: 2, // 500 * 2 = 1000 (user1의 가격 사용) @@ -308,13 +282,11 @@ describe("ContentWageService", () => { }, ]; - const result1 = await service.calculateGold(rewards); + const result1 = await service.calculateGold(rewards, user1.id); expect(result1).toBe(1000); // user1의 가격 적용 // 두 번째 사용자로 로그인 변경 - userContentService["context"].req.user = { id: user2.id }; - - const result2 = await service.calculateGold(rewards); + const result2 = await service.calculateGold(rewards, user2.id); expect(result2).toBe(1600); // user2의 가격 적용 }); }); @@ -324,9 +296,6 @@ describe("ContentWageService", () => { const prices: number[] = []; beforeAll(async () => { - userGoldExchangeRateService["context"].req.user = { id: undefined }; - userContentService["context"].req.user = { id: undefined }; - for (let i = 0; i < 3; i++) { const price = (i + 1) * 100; prices.push(price); @@ -360,7 +329,7 @@ describe("ContentWageService", () => { }, ]; - const result = await service.calculateSeeMoreRewardsGold(contentSeeMoreRewards); + const result = await service.calculateSeeMoreRewardsGold(contentSeeMoreRewards, undefined); expect(result).toBe(1100); }); @@ -380,7 +349,7 @@ describe("ContentWageService", () => { }, ]; - const result = await service.calculateSeeMoreRewardsGold(contentSeeMoreRewards, [ + const result = await service.calculateSeeMoreRewardsGold(contentSeeMoreRewards, undefined, [ itemIds[0], itemIds[1], ]); @@ -389,15 +358,12 @@ describe("ContentWageService", () => { it("빈 배열이 입력된 경우", async () => { const contentSeeMoreRewards = []; - const result = await service.calculateSeeMoreRewardsGold(contentSeeMoreRewards); + const result = await service.calculateSeeMoreRewardsGold(contentSeeMoreRewards, undefined); expect(result).toBe(0); }); it("사용자 정의 가격 사용", async () => { const user = await userFactory.create(); - userContentService["context"].req.user = { id: user.id }; - userGoldExchangeRateService["context"].req.user = { id: user.id }; - const items = await Promise.all([ itemFactory.create({ data: { @@ -440,7 +406,7 @@ describe("ContentWageService", () => { }, ]; - const result = await service.calculateSeeMoreRewardsGold(contentSeeMoreRewards); + const result = await service.calculateSeeMoreRewardsGold(contentSeeMoreRewards, user.id); expect(result).toBe(2500); }); @@ -456,7 +422,7 @@ describe("ContentWageService", () => { }, ]; - const result = await service.calculateSeeMoreRewardsGold(contentSeeMoreRewards); + const result = await service.calculateSeeMoreRewardsGold(contentSeeMoreRewards, undefined); expect(result).toBe(0); }); @@ -496,7 +462,7 @@ describe("ContentWageService", () => { }, ]; - const result = await service.calculateSeeMoreRewardsGold(contentSeeMoreRewards); + const result = await service.calculateSeeMoreRewardsGold(contentSeeMoreRewards, undefined); // 현재 구현에서는 중복 제거 없이 모든 보상을 합산함 // itemIds[0]: 100 * 2 + 100 * 3 = 500 @@ -513,9 +479,6 @@ describe("ContentWageService", () => { let testCategory: any; beforeAll(async () => { - userContentService["context"].req.user = { id: undefined }; - userGoldExchangeRateService["context"].req.user = { id: undefined }; - // 기본 환율 데이터 생성 (다른 테스트와 중복 방지) const existingExchangeRate = await prisma.goldExchangeRate.findFirst(); if (!existingExchangeRate) { @@ -596,9 +559,9 @@ describe("ContentWageService", () => { it("다중 컨텐츠 수익 합산 정확성 검증", async () => { const contentIds = [testContents[0].id, testContents[1].id]; - const filter = { includeIsBound: false }; + const filter = { includeBound: false }; - const result = await service.getContentGroupWage(contentIds, filter); + const result = await service.getContentGroupWage(contentIds, undefined, filter); // 예상 계산: // 컨텐츠1: 100 * 2 = 200 골드, 10분 = 600초 @@ -635,34 +598,19 @@ describe("ContentWageService", () => { }, }), ]); - - userContentService["context"].req.user = { id: exchangeRateUser.id }; - userGoldExchangeRateService["context"].req.user = { - id: exchangeRateUser.id, - }; - const contentIds = [testContents[0].id]; - const filter = { includeIsBound: false }; + const filter = { includeBound: false }; - const result = await service.getContentGroupWage(contentIds, filter); + const result = await service.getContentGroupWage(contentIds, exchangeRateUser.id, filter); // 사용자 환율 적용: 30KRW/100Gold // 200골드 / (600초/3600) = 1200골드/시간 // 1200골드/시간 * 30KRW/100골드 = 360KRW/시간 expect(result.krwAmountPerHour).toBe(360); expect(result.goldAmountPerHour).toBe(1200); - - // 테스트 후 컨텍스트 리셋 - userContentService["context"].req.user = { id: undefined }; - userGoldExchangeRateService["context"].req.user = { id: undefined }; }); it("사용자별 커스텀 설정 적용 검증", async () => { - // 사용자 컨텍스트 설정 - userContentService["context"].req.user = { id: testUser.id }; - userGoldExchangeRateService["context"].req.user = { id: testUser.id }; - - // 사용자별 아이템 가격 설정 await prisma.userItem.create({ data: { itemId: testItems[0].id, @@ -672,23 +620,19 @@ describe("ContentWageService", () => { }); const contentIds = [testContents[0].id]; - const filter = { includeIsBound: false }; + const filter = { includeBound: false }; - const result = await service.getContentGroupWage(contentIds, filter); + const result = await service.getContentGroupWage(contentIds, testUser.id, filter); // 사용자 가격 적용: 500 * 2 = 1000골드 expect(result.goldAmountPerClear).toBe(1000); - - // 테스트 후 컨텍스트 리셋 - userContentService["context"].req.user = { id: undefined }; - userGoldExchangeRateService["context"].req.user = { id: undefined }; }); it("빈 배열 입력 시 처리", async () => { const contentIds = []; - const filter = { includeIsBound: false }; + const filter = { includeBound: false }; - const result = await service.getContentGroupWage(contentIds, filter); + const result = await service.getContentGroupWage(contentIds, undefined, filter); expect(result.goldAmountPerClear).toBe(0); expect(result.goldAmountPerHour).toBeNaN(); // 0/0 = NaN @@ -733,9 +677,9 @@ describe("ContentWageService", () => { ]); const contentIds = [mixedContent.id]; - const filter = { includeIsBound: false }; + const filter = { includeBound: false }; - const result = await service.getContentGroupWage(contentIds, filter); + const result = await service.getContentGroupWage(contentIds, undefined, filter); // 100 * 1 + 300 * 2 = 700골드 expect(result.goldAmountPerClear).toBe(700); @@ -744,15 +688,11 @@ describe("ContentWageService", () => { it("환율 데이터 없을 때 fallback 처리", async () => { // 기본 환율 데이터 삭제 await prisma.goldExchangeRate.deleteMany({}); - - userContentService["context"].req.user = { id: undefined }; - userGoldExchangeRateService["context"].req.user = { id: undefined }; - const contentIds = [testContents[0].id]; - const filter = { includeIsBound: false }; + const filter = { includeBound: false }; // 환율 데이터가 없으면 에러가 발생해야 함 - await expect(service.getContentGroupWage(contentIds, filter)).rejects.toThrow(); + await expect(service.getContentGroupWage(contentIds, undefined, filter)).rejects.toThrow(); // 환율 데이터 복구 await prisma.goldExchangeRate.create({ @@ -766,13 +706,15 @@ describe("ContentWageService", () => { it("null/undefined 컨텐츠 필터링", async () => { const validContentId = testContents[0].id; const invalidContentIds = [null, undefined, validContentId, null]; - const filter = { includeIsBound: false }; + const filter = { includeBound: false }; // null/undefined가 포함된 배열은 에러를 발생시켜야 함 - await expect(service.getContentGroupWage(invalidContentIds as any, filter)).rejects.toThrow(); + await expect( + service.getContentGroupWage(invalidContentIds as any, undefined, filter) + ).rejects.toThrow(); // 유효한 contentId만 포함된 배열은 정상 작동해야 함 - const result = await service.getContentGroupWage([validContentId], filter); + const result = await service.getContentGroupWage([validContentId], undefined, filter); expect(result.goldAmountPerClear).toBe(200); }); }); diff --git a/src/backend/src/content/wage/wage.service.ts b/src/backend/src/content/wage/wage.service.ts new file mode 100644 index 00000000..b35a87d0 --- /dev/null +++ b/src/backend/src/content/wage/wage.service.ts @@ -0,0 +1,157 @@ +import { Injectable } from "@nestjs/common"; +import { sum, sumBy } from "es-toolkit"; +import { PrismaService } from "src/prisma"; +import { UserGoldExchangeRateService } from "src/user/service/user-gold-exchange-rate.service"; +import { UserContentService } from "../../user/service/user-content.service"; + +type Reward = { + averageQuantity: number; + itemId: number; +}; + +type WageFilter = { + includeBound?: boolean; + includeItemIds?: number[]; + includeSeeMore?: boolean; +}; + +type ContentWageData = { + duration: number; + gold: number; +}; + +@Injectable() +export class ContentWageService { + private static readonly SECONDS_PER_HOUR = 3600; + + constructor( + private userContentService: UserContentService, + private userGoldExchangeRateService: UserGoldExchangeRateService, + private prisma: PrismaService + ) {} + + async calculateGold(rewards: Reward[], userId?: number) { + const goldValues = await Promise.all( + rewards.map(async (reward) => { + const price = await this.userContentService.getItemPrice(reward.itemId, userId); + return price * reward.averageQuantity; + }) + ); + + return sum(goldValues); + } + + async calculateSeeMoreRewardsGold( + contentSeeMoreRewards: { + itemId: number; + quantity: number; + }[], + userId?: number, + includeItemIds?: number[] + ) { + const seeMoreRewards = contentSeeMoreRewards + .filter((reward) => !includeItemIds || includeItemIds.includes(reward.itemId)) + .map((reward) => ({ + averageQuantity: reward.quantity, + itemId: reward.itemId, + })); + + return await this.calculateGold(seeMoreRewards, userId); + } + + async calculateWage({ duration, gold }: { duration: number; gold: number }, userId?: number) { + const goldExchangeRate = await this.userGoldExchangeRateService.getGoldExchangeRate(userId); + + const totalKRW = (gold * goldExchangeRate.krwAmount) / goldExchangeRate.goldAmount; + + const hours = duration / ContentWageService.SECONDS_PER_HOUR; + const hourlyWage = totalKRW / hours; + const hourlyGold = gold / hours; + + return { + goldAmountPerHour: Math.round(hourlyGold), + krwAmountPerHour: Math.round(hourlyWage), + }; + } + + async getContentGroupWage(contentIds: number[], userId: number | undefined, filter: WageFilter) { + const dataList = await Promise.all( + contentIds.map((id) => this.calculateContentWageData(id, userId, filter)) + ); + + const totalGold = sumBy(dataList, (data) => data.gold); + const totalDuration = sumBy(dataList, (data) => data.duration); + + const { goldAmountPerHour, krwAmountPerHour } = await this.calculateWage( + { duration: totalDuration, gold: totalGold }, + userId + ); + + return { + goldAmountPerClear: Math.round(totalGold), + goldAmountPerHour, + krwAmountPerHour, + }; + } + + async getContentWage(contentId: number, userId: number | undefined, filter: WageFilter) { + const { duration, gold } = await this.calculateContentWageData(contentId, userId, filter); + + const { goldAmountPerHour, krwAmountPerHour } = await this.calculateWage( + { duration, gold }, + userId + ); + + return { + contentId, + goldAmountPerClear: Math.round(gold), + goldAmountPerHour, + krwAmountPerHour, + }; + } + + private async calculateContentWageData( + contentId: number, + userId: number | undefined, + filter: WageFilter + ): Promise { + const content = await this.prisma.content.findUniqueOrThrow({ + where: { id: contentId }, + }); + + const rewards = await this.userContentService.getContentRewards(content.id, userId, { + includeBound: filter?.includeBound, + includeItemIds: filter?.includeItemIds, + }); + + const seeMoreRewards = await this.userContentService.getContentSeeMoreRewards( + content.id, + userId, + { + includeItemIds: filter?.includeItemIds, + } + ); + + const rewardsGold = await this.calculateGold(rewards, userId); + + const shouldIncludeSeeMoreRewards = this.shouldIncludeSeeMore(filter, seeMoreRewards); + + const seeMoreGold = shouldIncludeSeeMoreRewards + ? await this.calculateSeeMoreRewardsGold(seeMoreRewards, userId, filter.includeItemIds) + : 0; + + const gold = rewardsGold + seeMoreGold; + const duration = await this.userContentService.getContentDuration(content.id, userId); + + return { duration, gold }; + } + + private shouldIncludeSeeMore( + filter: WageFilter, + seeMoreRewards: { itemId: number; quantity: number }[] + ): boolean { + return ( + filter?.includeSeeMore === true && filter?.includeBound !== false && seeMoreRewards.length > 0 + ); + } +} diff --git a/src/backend/src/dataloader/data-loader.service.ts b/src/backend/src/dataloader/data-loader.service.ts index 602b6b8a..a216d5b9 100644 --- a/src/backend/src/dataloader/data-loader.service.ts +++ b/src/backend/src/dataloader/data-loader.service.ts @@ -1,122 +1,44 @@ -import _ from "lodash"; -import DataLoader from "dataloader"; import { Injectable, Scope } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; import { PrismaService } from "src/prisma"; -import { ContentCategory, Item } from "@prisma/client"; -import { ItemSortOrder } from "src/content/constants"; +import { + ContentRewardWithItem, + ContentSeeMoreRewardWithItem, + ManyLoader, + UniqueLoader, +} from "./data-loader.types"; +import { createManyLoader, createUniqueLoader } from "./data-loader.utils"; @Injectable({ scope: Scope.REQUEST }) export class DataLoaderService { - readonly contentCategory = this.createContentCategoryLoader(); - readonly contentRewards = this.createContentRewardsLoader(); - readonly contentSeeMoreRewards = this.createContentSeeMoreRewardsLoader(); - readonly item = this.createItemLoader(); - - constructor(private prisma: PrismaService) {} - - private createContentCategoryLoader() { - const contentCategoryLoader = new DataLoader(async (categoryIds) => { - const categories = await this.prisma.contentCategory.findMany({ - where: { - id: { in: categoryIds as number[] }, - }, - }); - - const categoriesMap = _.keyBy(categories, "id"); - - return categoryIds.map((id) => categoriesMap[id]); - }); - - return { - findUniqueOrThrowById: async (categoryId: number) => { - const result = await contentCategoryLoader.load(categoryId); - if (!result) { - throw new Error(`ContentCategory with id ${categoryId} not found`); - } - return result; - }, - }; - } - - private createContentRewardsLoader() { - const contentRewardsLoader = new DataLoader(async (contentIds) => { - const rewards = await this.prisma.contentReward.findMany({ - include: { - item: true, - }, - where: { - contentId: { in: contentIds as number[] }, - }, - }); - - const sortedRewards = _.cloneDeep(rewards).sort((a, b) => { - const aOrder = ItemSortOrder[a.item.name] || 999; - const bOrder = ItemSortOrder[b.item.name] || 999; - return aOrder - bOrder; - }); - - const rewardsGrouped = _.groupBy(sortedRewards, "contentId"); - - return contentIds.map((id) => rewardsGrouped[id] || []); - }); - - return { - findManyByContentId: async (contentId: number) => { - return await contentRewardsLoader.load(contentId); - }, - }; - } - - private createContentSeeMoreRewardsLoader() { - const contentSeeMoreRewardsLoader = new DataLoader(async (contentIds) => { - const rewards = await this.prisma.contentSeeMoreReward.findMany({ - include: { - item: true, - }, - where: { - contentId: { in: contentIds as number[] }, - }, - }); - - const sortedRewards = _.cloneDeep(rewards).sort((a, b) => { - const aOrder = ItemSortOrder[a.item.name] || 999; - const bOrder = ItemSortOrder[b.item.name] || 999; - return aOrder - bOrder; - }); - - const rewardsGrouped = _.groupBy(sortedRewards, "contentId"); - - return contentIds.map((id) => rewardsGrouped[id] || []); - }); - - return { - findManyByContentId: async (contentId: number) => { - return await contentSeeMoreRewardsLoader.load(contentId); - }, - }; - } - - private createItemLoader() { - const itemLoader = new DataLoader(async (itemIds) => { - const items = await this.prisma.item.findMany({ - where: { - id: { in: itemIds as number[] }, - }, - }); - - const itemsMap = _.keyBy(items, "id"); - - return itemIds.map((id) => itemsMap[id]); - }); - - return { - findUniqueOrThrowById: async (itemId: number) => { - const result = await itemLoader.load(itemId); - if (!result) { - throw new Error(`Item with id ${itemId} not found`); - } - return result; - }, - }; + readonly contentCategory: UniqueLoader>; + readonly contentRewards: ManyLoader; + readonly contentSeeMoreRewards: ManyLoader; + readonly item: UniqueLoader>; + + constructor(private prisma: PrismaService) { + this.contentCategory = createUniqueLoader( + (ids) => this.prisma.contentCategory.findMany({ where: { id: { in: [...ids] } } }), + "ContentCategory" + ); + + this.contentRewards = createManyLoader((ids) => + this.prisma.contentReward.findMany({ + include: { item: true }, + where: { contentId: { in: [...ids] } }, + }) + ); + + this.contentSeeMoreRewards = createManyLoader((ids) => + this.prisma.contentSeeMoreReward.findMany({ + include: { item: true }, + where: { contentId: { in: [...ids] } }, + }) + ); + + this.item = createUniqueLoader( + (ids) => this.prisma.item.findMany({ where: { id: { in: [...ids] } } }), + "Item" + ); } } diff --git a/src/backend/src/dataloader/data-loader.types.ts b/src/backend/src/dataloader/data-loader.types.ts new file mode 100644 index 00000000..4cf7e867 --- /dev/null +++ b/src/backend/src/dataloader/data-loader.types.ts @@ -0,0 +1,17 @@ +import { Prisma } from "@prisma/client"; + +export type ManyLoader = { + findManyByContentId: (contentId: number) => Promise; +}; + +export type UniqueLoader = { + findUniqueOrThrowById: (id: number) => Promise; +}; + +export type ContentRewardWithItem = Prisma.ContentRewardGetPayload<{ + include: { item: true }; +}>; + +export type ContentSeeMoreRewardWithItem = Prisma.ContentSeeMoreRewardGetPayload<{ + include: { item: true }; +}>; diff --git a/src/backend/src/dataloader/data-loader.utils.spec.ts b/src/backend/src/dataloader/data-loader.utils.spec.ts new file mode 100644 index 00000000..1f5b09d5 --- /dev/null +++ b/src/backend/src/dataloader/data-loader.utils.spec.ts @@ -0,0 +1,121 @@ +import { createManyLoader, createUniqueLoader } from "./data-loader.utils"; + +describe("createUniqueLoader", () => { + it("존재하는 엔티티 로드 시 정상 반환", async () => { + const mockData = [ + { id: 1, name: "Item1" }, + { id: 2, name: "Item2" }, + ]; + const batchFn = jest.fn().mockResolvedValue(mockData); + + const loader = createUniqueLoader(batchFn, "Item"); + const result = await loader.findUniqueOrThrowById(1); + + expect(result).toEqual({ id: 1, name: "Item1" }); + }); + + it("존재하지 않는 ID 로드 시 에러 throw", async () => { + const batchFn = jest.fn().mockResolvedValue([]); + + const loader = createUniqueLoader(batchFn, "Item"); + + await expect(loader.findUniqueOrThrowById(999)).rejects.toThrow("Item with id 999 not found"); + }); + + it("여러 ID 동시 요청 시 배칭 동작", async () => { + const mockData = [ + { id: 1, name: "Item1" }, + { id: 2, name: "Item2" }, + { id: 3, name: "Item3" }, + ]; + const batchFn = jest.fn().mockResolvedValue(mockData); + + const loader = createUniqueLoader(batchFn, "Item"); + + const [result1, result2, result3] = await Promise.all([ + loader.findUniqueOrThrowById(1), + loader.findUniqueOrThrowById(2), + loader.findUniqueOrThrowById(3), + ]); + + expect(batchFn).toHaveBeenCalledTimes(1); + expect(batchFn).toHaveBeenCalledWith([1, 2, 3]); + expect(result1).toEqual({ id: 1, name: "Item1" }); + expect(result2).toEqual({ id: 2, name: "Item2" }); + expect(result3).toEqual({ id: 3, name: "Item3" }); + }); +}); + +describe("createManyLoader", () => { + it("정상적으로 다중 엔티티 로드", async () => { + const mockData = [ + { contentId: 1, item: { name: "골드" } }, + { contentId: 1, item: { name: "실링" } }, + ]; + const batchFn = jest.fn().mockResolvedValue(mockData); + + const loader = createManyLoader(batchFn); + const result = await loader.findManyByContentId(1); + + expect(result).toHaveLength(2); + }); + + it("ItemSortOrder에 따른 정렬", async () => { + const mockData = [ + { contentId: 1, item: { name: "실링" } }, // order: 10 + { contentId: 1, item: { name: "골드" } }, // order: 1 + { contentId: 1, item: { name: "운명의 파편" } }, // order: 3 + ]; + const batchFn = jest.fn().mockResolvedValue(mockData); + + const loader = createManyLoader(batchFn); + const result = await loader.findManyByContentId(1); + + expect(result[0].item.name).toBe("골드"); + expect(result[1].item.name).toBe("운명의 파편"); + expect(result[2].item.name).toBe("실링"); + }); + + it("존재하지 않는 contentId에 대해 빈 배열 반환", async () => { + const batchFn = jest.fn().mockResolvedValue([]); + + const loader = createManyLoader(batchFn); + const result = await loader.findManyByContentId(999); + + expect(result).toEqual([]); + }); + + it("여러 contentId 동시 요청 시 배칭 동작", async () => { + const mockData = [ + { contentId: 1, item: { name: "골드" } }, + { contentId: 2, item: { name: "실링" } }, + ]; + const batchFn = jest.fn().mockResolvedValue(mockData); + + const loader = createManyLoader(batchFn); + + const [result1, result2] = await Promise.all([ + loader.findManyByContentId(1), + loader.findManyByContentId(2), + ]); + + expect(batchFn).toHaveBeenCalledTimes(1); + expect(batchFn).toHaveBeenCalledWith([1, 2]); + expect(result1).toHaveLength(1); + expect(result2).toHaveLength(1); + }); + + it("ItemSortOrder에 없는 아이템은 마지막 정렬", async () => { + const mockData = [ + { contentId: 1, item: { name: "알 수 없는 아이템" } }, // order: 999 + { contentId: 1, item: { name: "골드" } }, // order: 1 + ]; + const batchFn = jest.fn().mockResolvedValue(mockData); + + const loader = createManyLoader(batchFn); + const result = await loader.findManyByContentId(1); + + expect(result[0].item.name).toBe("골드"); + expect(result[1].item.name).toBe("알 수 없는 아이템"); + }); +}); diff --git a/src/backend/src/dataloader/data-loader.utils.ts b/src/backend/src/dataloader/data-loader.utils.ts new file mode 100644 index 00000000..411d3243 --- /dev/null +++ b/src/backend/src/dataloader/data-loader.utils.ts @@ -0,0 +1,48 @@ +import DataLoader from "dataloader"; +import { groupBy, keyBy } from "es-toolkit"; +import { ItemSortOrder } from "src/content/shared/constants"; +import { ManyLoader, UniqueLoader } from "./data-loader.types"; + +const UNORDERED_ITEM_SORT_PRIORITY = 999; + +export const createUniqueLoader = ( + batchFn: (ids: readonly number[]) => Promise, + entityName: string +): UniqueLoader => { + const loader = new DataLoader(async (ids) => { + const items = await batchFn(ids); + const itemsMap = keyBy(items, (item) => item.id); + return ids.map((id) => itemsMap[id]); + }); + + return { + findUniqueOrThrowById: async (id: number) => { + const result = await loader.load(id); + if (!result) { + throw new Error(`${entityName} with id ${id} not found`); + } + return result; + }, + }; +}; + +export const createManyLoader = ( + batchFn: (ids: readonly number[]) => Promise +): ManyLoader => { + const loader = new DataLoader(async (contentIds) => { + const items = await batchFn(contentIds); + + const sortedItems = [...items].sort((a, b) => { + const aOrder = ItemSortOrder[a.item.name] ?? UNORDERED_ITEM_SORT_PRIORITY; + const bOrder = ItemSortOrder[b.item.name] ?? UNORDERED_ITEM_SORT_PRIORITY; + return aOrder - bOrder; + }); + + const grouped = groupBy(sortedItems, (item) => item.contentId); + return contentIds.map((id) => grouped[id] ?? []); + }); + + return { + findManyByContentId: async (contentId: number) => loader.load(contentId), + }; +}; diff --git a/src/backend/src/exchange-rate/exchange-rate.module.ts b/src/backend/src/exchange-rate/exchange-rate.module.ts index f8a0ac36..96c513fc 100644 --- a/src/backend/src/exchange-rate/exchange-rate.module.ts +++ b/src/backend/src/exchange-rate/exchange-rate.module.ts @@ -1,12 +1,12 @@ import { Module } from "@nestjs/common"; +import { DiscordModule } from "src/discord/discord.module"; import { PrismaModule } from "src/prisma"; -import { GoldExchangeRateQuery } from "./query/gold-exchange-rate.query"; -import { GoldExchangeRateEditMutation } from "./mutation/gold-exchange-rate-edit.mutation"; import { UserGoldExchangeRateService } from "src/user/service/user-gold-exchange-rate.service"; -import { DiscordModule } from "src/discord/discord.module"; +import { GoldExchangeRateResolver } from "./gold-exchange-rate.resolver"; +import { GoldExchangeRateService } from "./gold-exchange-rate.service"; @Module({ imports: [PrismaModule, DiscordModule], - providers: [GoldExchangeRateQuery, GoldExchangeRateEditMutation, UserGoldExchangeRateService], + providers: [GoldExchangeRateResolver, GoldExchangeRateService, UserGoldExchangeRateService], }) export class ExchangeRateModule {} diff --git a/src/backend/src/exchange-rate/gold-exchange-rate.dto.spec.ts b/src/backend/src/exchange-rate/gold-exchange-rate.dto.spec.ts new file mode 100644 index 00000000..1e0e59b7 --- /dev/null +++ b/src/backend/src/exchange-rate/gold-exchange-rate.dto.spec.ts @@ -0,0 +1,43 @@ +import { validate } from "class-validator"; +import { plainToInstance } from "class-transformer"; +import { EditGoldExchangeRateInput } from "./gold-exchange-rate.dto"; + +describe("EditGoldExchangeRateInput Validation", () => { + it("유효한 환율을 허용해야 함", async () => { + const input = plainToInstance(EditGoldExchangeRateInput, { + krwAmount: 500, + }); + + const errors = await validate(input); + expect(errors).toHaveLength(0); + }); + + it("krwAmount가 1 미만이면 에러", async () => { + const input = plainToInstance(EditGoldExchangeRateInput, { + krwAmount: 0, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("krwAmount"); + }); + + it("krwAmount가 음수면 에러", async () => { + const input = plainToInstance(EditGoldExchangeRateInput, { + krwAmount: -1, + }); + + const errors = await validate(input); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe("krwAmount"); + }); + + it("krwAmount가 1이면 허용 (경계값)", async () => { + const input = plainToInstance(EditGoldExchangeRateInput, { + krwAmount: 1, + }); + + const errors = await validate(input); + expect(errors).toHaveLength(0); + }); +}); diff --git a/src/backend/src/exchange-rate/gold-exchange-rate.dto.ts b/src/backend/src/exchange-rate/gold-exchange-rate.dto.ts new file mode 100644 index 00000000..6568b76e --- /dev/null +++ b/src/backend/src/exchange-rate/gold-exchange-rate.dto.ts @@ -0,0 +1,14 @@ +import { Field, InputType, Int, ObjectType } from "@nestjs/graphql"; +import { IsNumber, Min } from "class-validator"; +import { MutationResult } from "src/common/dto/mutation-result.dto"; + +@InputType() +export class EditGoldExchangeRateInput { + @Field(() => Int, { description: "100골드당 원화 금액" }) + @IsNumber() + @Min(1) + krwAmount: number; +} + +@ObjectType() +export class EditGoldExchangeRateResult extends MutationResult {} diff --git a/src/backend/src/exchange-rate/object/gold-exchange-rate.object.ts b/src/backend/src/exchange-rate/gold-exchange-rate.object.ts similarity index 100% rename from src/backend/src/exchange-rate/object/gold-exchange-rate.object.ts rename to src/backend/src/exchange-rate/gold-exchange-rate.object.ts diff --git a/src/backend/src/exchange-rate/gold-exchange-rate.resolver.ts b/src/backend/src/exchange-rate/gold-exchange-rate.resolver.ts new file mode 100644 index 00000000..4c388fe8 --- /dev/null +++ b/src/backend/src/exchange-rate/gold-exchange-rate.resolver.ts @@ -0,0 +1,32 @@ +import { UseGuards } from "@nestjs/common"; +import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; +import { User as PrismaUser } from "@prisma/client"; +import { AuthGuard } from "src/auth/auth.guard"; +import { CurrentUser } from "src/common/decorator/current-user.decorator"; +import { User } from "src/common/object/user.object"; +import { UserGoldExchangeRateService } from "src/user/service/user-gold-exchange-rate.service"; +import { EditGoldExchangeRateInput, EditGoldExchangeRateResult } from "./gold-exchange-rate.dto"; +import { GoldExchangeRate } from "./gold-exchange-rate.object"; +import { GoldExchangeRateService } from "./gold-exchange-rate.service"; + +@Resolver() +export class GoldExchangeRateResolver { + constructor( + private goldExchangeRateService: GoldExchangeRateService, + private userGoldExchangeRateService: UserGoldExchangeRateService + ) {} + + @Query(() => GoldExchangeRate) + async goldExchangeRate(@CurrentUser() user?: PrismaUser) { + return await this.userGoldExchangeRateService.getGoldExchangeRate(user?.id); + } + + @UseGuards(AuthGuard) + @Mutation(() => EditGoldExchangeRateResult) + async goldExchangeRateEdit( + @Args("input") input: EditGoldExchangeRateInput, + @CurrentUser() user: User + ) { + return await this.goldExchangeRateService.editGoldExchangeRate(input.krwAmount, user); + } +} diff --git a/src/backend/src/exchange-rate/mutation/gold-exchange-rate-edit.mutation.ts b/src/backend/src/exchange-rate/gold-exchange-rate.service.ts similarity index 64% rename from src/backend/src/exchange-rate/mutation/gold-exchange-rate-edit.mutation.ts rename to src/backend/src/exchange-rate/gold-exchange-rate.service.ts index 04f95af5..61a52475 100644 --- a/src/backend/src/exchange-rate/mutation/gold-exchange-rate-edit.mutation.ts +++ b/src/backend/src/exchange-rate/gold-exchange-rate.service.ts @@ -1,60 +1,17 @@ -import { UseGuards } from "@nestjs/common"; -import { Args, Field, InputType, Int, Mutation, ObjectType, Resolver } from "@nestjs/graphql"; +import { Injectable } from "@nestjs/common"; import { Prisma, UserRole } from "@prisma/client"; -import { AuthGuard } from "src/auth/auth.guard"; -import { CurrentUser } from "src/common/decorator/current-user.decorator"; import { User } from "src/common/object/user.object"; import { DiscordService } from "src/discord/discord.service"; import { PrismaService } from "src/prisma"; -@InputType() -class GoldExchangeRateEditInput { - @Field(() => Int) - krwAmount: number; -} - -@ObjectType() -class GoldExchangeRateEditResult { - @Field(() => Boolean) - ok: boolean; -} - -@Resolver() -export class GoldExchangeRateEditMutation { +@Injectable() +export class GoldExchangeRateService { constructor( private prisma: PrismaService, private discordService: DiscordService ) {} - async editDefaultGoldExchangeRate(krwAmount: number, tx: Prisma.TransactionClient) { - const goldExchangeRate = await tx.goldExchangeRate.findFirstOrThrow(); - - const updatedGoldExchangeRate = await tx.goldExchangeRate.update({ - data: { - krwAmount, - }, - where: { - id: goldExchangeRate.id, - }, - }); - - if (process.env.NODE_ENV !== "production") return; - - const before = `${goldExchangeRate.goldAmount}:${goldExchangeRate.krwAmount}`; - const after = `${updatedGoldExchangeRate.goldAmount}:${updatedGoldExchangeRate.krwAmount}`; - const message = `서버 골드 환율 변경\n**${before}** -> **${after}**`; - - await this.discordService.sendMessage(message); - } - - @UseGuards(AuthGuard) - @Mutation(() => GoldExchangeRateEditResult) - async goldExchangeRateEdit( - @Args("input") input: GoldExchangeRateEditInput, - @CurrentUser() user: User - ) { - const { krwAmount } = input; - + async editGoldExchangeRate(krwAmount: number, user: User) { return await this.prisma.$transaction(async (tx) => { if (user.role === UserRole.OWNER) { await this.editDefaultGoldExchangeRate(krwAmount, tx); @@ -75,4 +32,25 @@ export class GoldExchangeRateEditMutation { return { ok: true }; }); } + + private async editDefaultGoldExchangeRate(krwAmount: number, tx: Prisma.TransactionClient) { + const goldExchangeRate = await tx.goldExchangeRate.findFirstOrThrow(); + + const updatedGoldExchangeRate = await tx.goldExchangeRate.update({ + data: { + krwAmount, + }, + where: { + id: goldExchangeRate.id, + }, + }); + + if (process.env.NODE_ENV !== "production") return; + + const before = `${goldExchangeRate.goldAmount}:${goldExchangeRate.krwAmount}`; + const after = `${updatedGoldExchangeRate.goldAmount}:${updatedGoldExchangeRate.krwAmount}`; + const message = `서버 골드 환율 변경\n**${before}** -> **${after}**`; + + await this.discordService.sendMessage(message); + } } diff --git a/src/backend/src/exchange-rate/query/gold-exchange-rate.query.ts b/src/backend/src/exchange-rate/query/gold-exchange-rate.query.ts deleted file mode 100644 index 7e9e76df..00000000 --- a/src/backend/src/exchange-rate/query/gold-exchange-rate.query.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Query, Resolver } from "@nestjs/graphql"; -import { GoldExchangeRate } from "../object/gold-exchange-rate.object"; -import { UserGoldExchangeRateService } from "src/user/service/user-gold-exchange-rate.service"; - -@Resolver() -export class GoldExchangeRateQuery { - constructor(private userGoldExchangeRateService: UserGoldExchangeRateService) {} - - @Query(() => GoldExchangeRate) - async goldExchangeRate() { - return await this.userGoldExchangeRateService.getGoldExchangeRate(); - } -} diff --git a/src/backend/src/item/object/auction-item-stat.object.ts b/src/backend/src/item/auction-item/auction-item-stat.object.ts similarity index 100% rename from src/backend/src/item/object/auction-item-stat.object.ts rename to src/backend/src/item/auction-item/auction-item-stat.object.ts diff --git a/src/backend/src/item/auction-item/auction-item.dto.ts b/src/backend/src/item/auction-item/auction-item.dto.ts new file mode 100644 index 00000000..79284bf9 --- /dev/null +++ b/src/backend/src/item/auction-item/auction-item.dto.ts @@ -0,0 +1,23 @@ +import { Field, InputType } from "@nestjs/graphql"; +import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator"; +import { ITEM_KEYWORD_MAX_LENGTH } from "src/common/constants/item.constants"; + +@InputType() +export class AuctionItemListFilter { + @Field(() => Boolean, { + description: "가격 통계 수집 활성화 여부", + nullable: true, + }) + @IsOptional() + @IsBoolean() + isStatScraperEnabled?: boolean; + + @Field(() => String, { + description: "아이템 이름 검색 키워드", + nullable: true, + }) + @IsOptional() + @IsString() + @MaxLength(ITEM_KEYWORD_MAX_LENGTH) + nameKeyword?: string; +} diff --git a/src/backend/src/item/object/auction-item.object.ts b/src/backend/src/item/auction-item/auction-item.object.ts similarity index 100% rename from src/backend/src/item/object/auction-item.object.ts rename to src/backend/src/item/auction-item/auction-item.object.ts diff --git a/src/backend/src/item/auction-item/auction-item.resolver.ts b/src/backend/src/item/auction-item/auction-item.resolver.ts new file mode 100644 index 00000000..0473c869 --- /dev/null +++ b/src/backend/src/item/auction-item/auction-item.resolver.ts @@ -0,0 +1,27 @@ +import { Args, Query, Resolver } from "@nestjs/graphql"; +import { OrderByArg } from "src/common/object/order-by-arg.object"; +import { AuctionItemListFilter } from "./auction-item.dto"; +import { AuctionItem } from "./auction-item.object"; +import { AuctionItemService } from "./auction-item.service"; + +@Resolver() +export class AuctionItemResolver { + constructor(private auctionItemService: AuctionItemService) {} + + @Query(() => [AuctionItem]) + async auctionItemList(@Args("filter", { nullable: true }) filter?: AuctionItemListFilter) { + return await this.auctionItemService.findAuctionItemList(filter); + } + + @Query(() => [AuctionItem]) + async auctionItems( + @Args("orderBy", { + nullable: true, + type: () => [OrderByArg], + }) + orderBy?: OrderByArg[], + @Args("take", { nullable: true }) take: number | null = 10 + ) { + return await this.auctionItemService.findAuctionItems(orderBy, take); + } +} diff --git a/src/backend/src/item/query/auction-item-list.query.ts b/src/backend/src/item/auction-item/auction-item.service.ts similarity index 54% rename from src/backend/src/item/query/auction-item-list.query.ts rename to src/backend/src/item/auction-item/auction-item.service.ts index 9c2ae7f8..c4d31aec 100644 --- a/src/backend/src/item/query/auction-item-list.query.ts +++ b/src/backend/src/item/auction-item/auction-item.service.ts @@ -1,28 +1,26 @@ -import { Args, Field, InputType, Query, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; +import { Injectable } from "@nestjs/common"; import { Prisma } from "@prisma/client"; -import { AuctionItem } from "../object/auction-item.object"; - -@InputType() -export class AuctionItemListFilter { - @Field(() => Boolean, { nullable: true }) - isStatScraperEnabled?: boolean; - - @Field(() => String, { nullable: true }) - nameKeyword?: string; -} +import { OrderByArg } from "src/common/object/order-by-arg.object"; +import { PrismaService } from "src/prisma"; +import { AuctionItemListFilter } from "./auction-item.dto"; -@Resolver() -export class AuctionItemListQuery { +@Injectable() +export class AuctionItemService { constructor(private prisma: PrismaService) {} - @Query(() => [AuctionItem]) - async auctionItemList(@Args("filter", { nullable: true }) filter?: AuctionItemListFilter) { + async findAuctionItemList(filter?: AuctionItemListFilter) { return await this.prisma.auctionItem.findMany({ where: this.buildWhereArgs(filter), }); } + async findAuctionItems(orderBy?: OrderByArg[], take: number | null = 10) { + return await this.prisma.auctionItem.findMany({ + orderBy: orderBy ? orderBy.map((o) => ({ [o.field]: o.order })) : undefined, + take, + }); + } + private buildWhereArgs(filter?: AuctionItemListFilter) { const whereArgs: Prisma.AuctionItemWhereInput = {}; diff --git a/src/backend/src/item/item.module.ts b/src/backend/src/item/item.module.ts index 6cba8e71..d86fb570 100644 --- a/src/backend/src/item/item.module.ts +++ b/src/backend/src/item/item.module.ts @@ -1,12 +1,12 @@ import { Module } from "@nestjs/common"; import { PrismaModule } from "src/prisma"; -import { MarketItemListQuery } from "./query/market-item-list.query"; -import { AuctionItemListQuery } from "./query/auction-item-list.query"; -import { MarketItemsQuery } from "./query/market-items.query"; -import { AuctionItemsQuery } from "./query/auction-items.query"; +import { AuctionItemResolver } from "./auction-item/auction-item.resolver"; +import { AuctionItemService } from "./auction-item/auction-item.service"; +import { MarketItemResolver } from "./market-item/market-item.resolver"; +import { MarketItemService } from "./market-item/market-item.service"; @Module({ imports: [PrismaModule], - providers: [MarketItemListQuery, AuctionItemListQuery, MarketItemsQuery, AuctionItemsQuery], + providers: [AuctionItemResolver, AuctionItemService, MarketItemResolver, MarketItemService], }) export class ItemModule {} diff --git a/src/backend/src/item/object/market-item-stat.object.ts b/src/backend/src/item/market-item/market-item-stat.object.ts similarity index 100% rename from src/backend/src/item/object/market-item-stat.object.ts rename to src/backend/src/item/market-item/market-item-stat.object.ts diff --git a/src/backend/src/item/market-item/market-item.dto.ts b/src/backend/src/item/market-item/market-item.dto.ts new file mode 100644 index 00000000..4ece8cb9 --- /dev/null +++ b/src/backend/src/item/market-item/market-item.dto.ts @@ -0,0 +1,36 @@ +import { Field, InputType } from "@nestjs/graphql"; +import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator"; +import { + ITEM_CATEGORY_NAME_MAX_LENGTH, + ITEM_GRADE_MAX_LENGTH, + ITEM_KEYWORD_MAX_LENGTH, +} from "src/common/constants/item.constants"; + +@InputType() +export class MarketItemListFilter { + @Field({ description: "카테고리 이름", nullable: true }) + @IsOptional() + @IsString() + @MaxLength(ITEM_CATEGORY_NAME_MAX_LENGTH) + categoryName?: string; + + @Field({ description: "등급", nullable: true }) + @IsOptional() + @IsString() + @MaxLength(ITEM_GRADE_MAX_LENGTH) + grade?: string; + + @Field(() => Boolean, { + description: "가격 통계 수집 활성화 여부", + nullable: true, + }) + @IsOptional() + @IsBoolean() + isStatScraperEnabled?: boolean; + + @Field({ description: "검색 키워드", nullable: true }) + @IsOptional() + @IsString() + @MaxLength(ITEM_KEYWORD_MAX_LENGTH) + keyword?: string; +} diff --git a/src/backend/src/item/object/market-item.object.ts b/src/backend/src/item/market-item/market-item.object.ts similarity index 100% rename from src/backend/src/item/object/market-item.object.ts rename to src/backend/src/item/market-item/market-item.object.ts diff --git a/src/backend/src/item/market-item/market-item.resolver.ts b/src/backend/src/item/market-item/market-item.resolver.ts new file mode 100644 index 00000000..254796d6 --- /dev/null +++ b/src/backend/src/item/market-item/market-item.resolver.ts @@ -0,0 +1,27 @@ +import { Args, Query, Resolver } from "@nestjs/graphql"; +import { OrderByArg } from "src/common/object/order-by-arg.object"; +import { MarketItemListFilter } from "./market-item.dto"; +import { MarketItem } from "./market-item.object"; +import { MarketItemService } from "./market-item.service"; + +@Resolver() +export class MarketItemResolver { + constructor(private marketItemService: MarketItemService) {} + + @Query(() => [MarketItem]) + async marketItemList(@Args("filter", { nullable: true }) filter?: MarketItemListFilter) { + return await this.marketItemService.findMarketItemList(filter); + } + + @Query(() => [MarketItem]) + async marketItems( + @Args("orderBy", { + nullable: true, + type: () => [OrderByArg], + }) + orderBy?: OrderByArg[], + @Args("take", { nullable: true }) take: number | null = 10 + ) { + return await this.marketItemService.findMarketItems(orderBy, take); + } +} diff --git a/src/backend/src/item/query/market-item-list.query.ts b/src/backend/src/item/market-item/market-item.service.ts similarity index 60% rename from src/backend/src/item/query/market-item-list.query.ts rename to src/backend/src/item/market-item/market-item.service.ts index 76490b4d..335677d8 100644 --- a/src/backend/src/item/query/market-item-list.query.ts +++ b/src/backend/src/item/market-item/market-item.service.ts @@ -1,34 +1,26 @@ -import { Args, Field, InputType, Query, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { MarketItem } from "../object/market-item.object"; +import { Injectable } from "@nestjs/common"; import { Prisma } from "@prisma/client"; +import { OrderByArg } from "src/common/object/order-by-arg.object"; +import { PrismaService } from "src/prisma"; +import { MarketItemListFilter } from "./market-item.dto"; -@InputType() -export class MarketItemListFilter { - @Field({ nullable: true }) - categoryName?: string; - - @Field({ nullable: true }) - grade?: string; - - @Field(() => Boolean, { nullable: true }) - isStatScraperEnabled?: boolean; - - @Field({ nullable: true }) - keyword?: string; -} - -@Resolver() -export class MarketItemListQuery { +@Injectable() +export class MarketItemService { constructor(private prisma: PrismaService) {} - @Query(() => [MarketItem]) - async marketItemList(@Args("filter", { nullable: true }) filter?: MarketItemListFilter) { + async findMarketItemList(filter?: MarketItemListFilter) { return await this.prisma.marketItem.findMany({ where: this.buildWhereArgs(filter), }); } + async findMarketItems(orderBy?: OrderByArg[], take: number | null = 10) { + return await this.prisma.marketItem.findMany({ + orderBy: orderBy ? orderBy.map((o) => ({ [o.field]: o.order })) : undefined, + take, + }); + } + private buildWhereArgs(filter?: MarketItemListFilter) { const whereArgs: Prisma.MarketItemWhereInput = {}; diff --git a/src/backend/src/item/query/auction-items.query.ts b/src/backend/src/item/query/auction-items.query.ts deleted file mode 100644 index e6fe0920..00000000 --- a/src/backend/src/item/query/auction-items.query.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Args, Query, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { OrderByArg } from "src/common/object/order-by-arg.object"; -import { AuctionItem } from "../object/auction-item.object"; - -@Resolver() -export class AuctionItemsQuery { - constructor(private prisma: PrismaService) {} - - @Query(() => [AuctionItem]) - async auctionItems( - @Args("orderBy", { - nullable: true, - type: () => [OrderByArg], - }) - orderBy?: OrderByArg[], - @Args("take", { nullable: true }) take: number | null = 10 - ) { - return await this.prisma.auctionItem.findMany({ - orderBy: orderBy ? orderBy.map((o) => ({ [o.field]: o.order })) : undefined, - take, - }); - } -} diff --git a/src/backend/src/item/query/market-items.query.ts b/src/backend/src/item/query/market-items.query.ts deleted file mode 100644 index ff3512fd..00000000 --- a/src/backend/src/item/query/market-items.query.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Args, Query, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; -import { OrderByArg } from "src/common/object/order-by-arg.object"; -import { MarketItem } from "../object/market-item.object"; - -@Resolver() -export class MarketItemsQuery { - constructor(private prisma: PrismaService) {} - - @Query(() => [MarketItem]) - async marketItems( - @Args("orderBy", { - nullable: true, - type: () => [OrderByArg], - }) - orderBy?: OrderByArg[], - @Args("take", { nullable: true }) take: number | null = 10 - ) { - return await this.prisma.marketItem.findMany({ - orderBy: orderBy ? orderBy.map((o) => ({ [o.field]: o.order })) : undefined, - take, - }); - } -} diff --git a/src/backend/src/main.ts b/src/backend/src/main.ts index 072fbff8..c6537f5f 100644 --- a/src/backend/src/main.ts +++ b/src/backend/src/main.ts @@ -1,3 +1,4 @@ +import { ValidationPipe } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import { AppModule } from "./app.module"; import "./enums"; @@ -5,6 +6,18 @@ import "./enums"; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.useGlobalPipes( + new ValidationPipe({ + skipNullProperties: true, // null 값 검증 스킵 + skipUndefinedProperties: true, // undefined 값 검증 스킵 (GraphQL optional 필드) + transform: true, // DTO 클래스 인스턴스로 변환 (중첩 객체 검증에 필요) + transformOptions: { + enableImplicitConversion: true, // GraphQL 스칼라 타입 자동 변환 허용 + }, + whitelist: false, // 선언되지 않은 속성 제거 안 함 (GraphQL과 충돌 방지) + }) + ); + await app.listen(process.env.PORT ?? 3000); } bootstrap(); diff --git a/src/backend/src/user/service/types.ts b/src/backend/src/user/service/types.ts deleted file mode 100644 index 5ca8b97c..00000000 --- a/src/backend/src/user/service/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type ContextType = { req?: { user?: { id: number } } }; diff --git a/src/backend/src/user/service/user-content.service.spec.ts b/src/backend/src/user/service/user-content.service.spec.ts index 668656e5..3c542274 100644 --- a/src/backend/src/user/service/user-content.service.spec.ts +++ b/src/backend/src/user/service/user-content.service.spec.ts @@ -1,10 +1,9 @@ import { Test, TestingModule } from "@nestjs/testing"; import { PrismaService } from "src/prisma"; import { UserContentService } from "../../user/service/user-content.service"; -import { CONTEXT } from "@nestjs/graphql"; import { UserGoldExchangeRateService } from "src/user/service/user-gold-exchange-rate.service"; import { User } from "@prisma/client"; -import { ContentWageService } from "src/content/service/content-wage.service"; +import { ContentWageService } from "src/content/wage/wage.service"; import { UserFactory } from "src/test/factory/user.factory"; import { ItemFactory } from "src/test/factory/item.factory"; import { ContentFactory } from "src/test/factory/content.factory"; @@ -34,10 +33,6 @@ describe("UserContentService", () => { ContentFactory, ContentDurationFactory, ContentRewardFactory, - { - provide: CONTEXT, - useValue: { req: { user: { id: undefined } } }, - }, ], }).compile(); @@ -167,8 +162,8 @@ describe("UserContentService", () => { ], }); - const results = await service.getContentRewards(content.id, { - includeIsBound: false, + const results = await service.getContentRewards(content.id, undefined, { + includeBound: false, }); expect(results).toHaveLength(1); @@ -213,7 +208,7 @@ describe("UserContentService", () => { ], }); - const results = await service.getContentRewards(content.id, { + const results = await service.getContentRewards(content.id, undefined, { includeItemIds: [item1.id, item3.id], }); @@ -264,8 +259,8 @@ describe("UserContentService", () => { ], }); - const results = await service.getContentRewards(content.id, { - includeIsBound: false, + const results = await service.getContentRewards(content.id, undefined, { + includeBound: false, includeItemIds: [item1.id, item2.id], }); @@ -293,7 +288,6 @@ describe("UserContentService", () => { beforeAll(async () => { user = await userFactory.create(); - service["context"].req.user = { id: user.id }; }); it("getItemPrice", async () => { @@ -313,7 +307,7 @@ describe("UserContentService", () => { }, }); - const result = await service.getItemPrice(item.id); + const result = await service.getItemPrice(item.id, user.id); expect(result).toBe(userPrice); }); @@ -327,7 +321,7 @@ describe("UserContentService", () => { }, }); - const result = await service.getItemPrice(item.id); + const result = await service.getItemPrice(item.id, user.id); expect(result).toBe(price); }); @@ -349,7 +343,7 @@ describe("UserContentService", () => { }, }); - const result = await service.getContentDuration(content.id); + const result = await service.getContentDuration(content.id, user.id); expect(result).toBe(duration); }); @@ -366,7 +360,7 @@ describe("UserContentService", () => { }, }); - const result = await service.getContentRewardAverageQuantity(contentReward.id); + const result = await service.getContentRewardAverageQuantity(contentReward.id, user.id); expect(result.toNumber()).toBeCloseTo(averageQuantity, 5); }); @@ -423,7 +417,7 @@ describe("UserContentService", () => { ], }); - const results = await service.getContentRewards(content.id); + const results = await service.getContentRewards(content.id, user.id); expect(results).toHaveLength(2); @@ -486,8 +480,8 @@ describe("UserContentService", () => { ], }); - const results = await service.getContentRewards(content.id, { - includeIsBound: false, + const results = await service.getContentRewards(content.id, user.id, { + includeBound: false, }); expect(results).toHaveLength(1); @@ -555,7 +549,7 @@ describe("UserContentService", () => { ], }); - const results = await service.getContentRewards(content.id, { + const results = await service.getContentRewards(content.id, user.id, { includeItemIds: [item1.id, item3.id], }); @@ -613,7 +607,7 @@ describe("UserContentService", () => { }, }); - const results = await service.getContentRewards(content.id); + const results = await service.getContentRewards(content.id, user.id); expect(results).toHaveLength(1); expect(results[0].itemId).toBe(item.id); diff --git a/src/backend/src/user/service/user-content.service.ts b/src/backend/src/user/service/user-content.service.ts index 943cdf51..6aeaead1 100644 --- a/src/backend/src/user/service/user-content.service.ts +++ b/src/backend/src/user/service/user-content.service.ts @@ -1,101 +1,78 @@ -import { Inject, Injectable, UseGuards } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; +import { keyBy } from "es-toolkit"; import { PrismaService } from "../../prisma"; -import { CONTEXT } from "@nestjs/graphql"; -import { ContextType } from "./types"; -import { AuthGuard } from "src/auth/auth.guard"; -@UseGuards(AuthGuard) @Injectable() export class UserContentService { - constructor( - private readonly prisma: PrismaService, - @Inject(CONTEXT) private context: ContextType - ) {} - - async getContentDuration(contentId: number) { - const userId = this.getUserId(); - - const contentDuration = await this.prisma.contentDuration.findUniqueOrThrow({ - where: { - contentId, - }, - }); - - if (userId) { - const userContentDuration = await this.prisma.userContentDuration.findUnique({ - where: { - contentId_userId: { - contentId, - userId, - }, - }, - }); - - return userContentDuration ? userContentDuration.value : contentDuration.value; - } - - return contentDuration.value; + constructor(private readonly prisma: PrismaService) {} + + async getContentDuration(contentId: number, userId?: number) { + return this.getUserOverride( + userId, + () => + this.prisma.contentDuration.findUniqueOrThrow({ + where: { contentId }, + }), + (userId) => + this.prisma.userContentDuration.findUnique({ + where: { contentId_userId: { contentId, userId } }, + }), + (entity) => entity.value + ); } - async getContentRewardAverageQuantity(contentRewardId: number) { - const userId = this.getUserId(); - + async getContentRewardAverageQuantity(contentRewardId: number, userId?: number) { const contentReward = await this.prisma.contentReward.findUniqueOrThrow({ - where: { - id: contentRewardId, - }, + where: { id: contentRewardId }, }); - if (userId) { - const userContentReward = await this.prisma.userContentReward.findUnique({ - where: { - userId_contentId_itemId: { - contentId: contentReward.contentId, - itemId: contentReward.itemId, - userId, + return this.getUserOverride( + userId, + () => Promise.resolve(contentReward), + (userId) => + this.prisma.userContentReward.findUnique({ + where: { + userId_contentId_itemId: { + contentId: contentReward.contentId, + itemId: contentReward.itemId, + userId, + }, }, - }, - }); - - return userContentReward ? userContentReward.averageQuantity : contentReward.averageQuantity; - } - - return contentReward.averageQuantity; + }), + (entity) => entity.averageQuantity + ); } - async getContentRewardIsSellable(contentRewardId: number) { - const userId = this.getUserId(); - + async getContentRewardIsSellable(contentRewardId: number, userId?: number) { const contentReward = await this.prisma.contentReward.findUniqueOrThrow({ where: { id: contentRewardId }, }); - if (userId) { - const userContentReward = await this.prisma.userContentReward.findUnique({ - where: { - userId_contentId_itemId: { - contentId: contentReward.contentId, - itemId: contentReward.itemId, - userId, + return this.getUserOverride( + userId, + () => Promise.resolve(contentReward), + (userId) => + this.prisma.userContentReward.findUnique({ + where: { + userId_contentId_itemId: { + contentId: contentReward.contentId, + itemId: contentReward.itemId, + userId, + }, }, - }, - }); - - return userContentReward ? userContentReward.isSellable : contentReward.isSellable; - } - - return contentReward.isSellable; + }), + (entity) => entity.isSellable + ); } async getContentRewards( contentId: number, + userId?: number, filter?: { - includeIsBound?: boolean; + includeBound?: boolean; includeItemIds?: number[]; } ) { - const userId = this.getUserId(); - const where = { contentId, ...(filter?.includeItemIds && { @@ -107,11 +84,13 @@ export class UserContentService { where, }); - let result: { + type RewardResult = { averageQuantity: number; isSellable: boolean; itemId: number; - }[]; + }; + + let result: RewardResult[]; if (userId) { const userRewards = await this.prisma.userContentReward.findMany({ @@ -124,24 +103,15 @@ export class UserContentService { }, }); - const userRewardMap = new Map(userRewards.map((reward) => [reward.itemId, reward])); + const userRewardByItemId = keyBy(userRewards, (r) => r.itemId); result = defaultRewards.map(({ averageQuantity, isSellable, itemId }) => { - const userReward = userRewardMap.get(itemId); - - if (userReward) { - return { - averageQuantity: userReward.averageQuantity.toNumber(), - isSellable: userReward.isSellable, - itemId, - }; - } else { - return { - averageQuantity: averageQuantity.toNumber(), - isSellable, - itemId, - }; - } + const userReward = userRewardByItemId[itemId]; + return { + averageQuantity: (userReward?.averageQuantity ?? averageQuantity).toNumber(), + isSellable: userReward?.isSellable ?? isSellable, + itemId, + }; }); } else { result = defaultRewards.map(({ averageQuantity, isSellable, itemId }) => ({ @@ -152,47 +122,42 @@ export class UserContentService { } return result.filter((item) => { - if (filter?.includeIsBound === false) { + if (filter?.includeBound === false) { return item.isSellable; } return true; }); } - async getContentSeeMoreRewardQuantity(contentSeeMoreRewardId: number) { - const userId = this.getUserId(); - + async getContentSeeMoreRewardQuantity(contentSeeMoreRewardId: number, userId?: number) { const contentSeeMoreReward = await this.prisma.contentSeeMoreReward.findUniqueOrThrow({ where: { id: contentSeeMoreRewardId }, }); - if (userId) { - const userContentSeeMoreReward = await this.prisma.userContentSeeMoreReward.findUnique({ - where: { - userId_contentId_itemId: { - contentId: contentSeeMoreReward.contentId, - itemId: contentSeeMoreReward.itemId, - userId, + return this.getUserOverride( + userId, + () => Promise.resolve(contentSeeMoreReward), + (userId) => + this.prisma.userContentSeeMoreReward.findUnique({ + where: { + userId_contentId_itemId: { + contentId: contentSeeMoreReward.contentId, + itemId: contentSeeMoreReward.itemId, + userId, + }, }, - }, - }); - - return userContentSeeMoreReward - ? userContentSeeMoreReward.quantity - : contentSeeMoreReward.quantity; - } - - return contentSeeMoreReward.quantity; + }), + (entity) => entity.quantity + ); } async getContentSeeMoreRewards( contentId: number, + userId?: number, filter?: { includeItemIds?: number[]; } ) { - const userId = this.getUserId(); - const where = { contentId, ...(filter?.includeItemIds && { @@ -215,15 +180,12 @@ export class UserContentService { }, }); - const userRewardMap = new Map(userRewards.map((reward) => [reward.itemId, reward])); + const userRewardByItemId = keyBy(userRewards, (r) => r.itemId); - return defaultRewards.map(({ itemId, quantity }) => { - const userReward = userRewardMap.get(itemId); - return { - itemId, - quantity: userReward ? userReward.quantity.toNumber() : quantity.toNumber(), - }; - }); + return defaultRewards.map(({ itemId, quantity }) => ({ + itemId, + quantity: (userRewardByItemId[itemId]?.quantity ?? quantity).toNumber(), + })); } return defaultRewards.map(({ itemId, quantity }) => ({ @@ -232,46 +194,23 @@ export class UserContentService { })); } - // Test 작성 - async getItemPrice(itemId: number) { - const userId = this.getUserId(); - - const { price: defaultPrice } = await this.prisma.item.findUniqueOrThrow({ - where: { - id: itemId, - }, + async getItemPrice(itemId: number, userId?: number) { + const item = await this.prisma.item.findUniqueOrThrow({ + where: { id: itemId }, }); - const { isEditable } = await this.prisma.item.findUniqueOrThrow({ - where: { - id: itemId, - }, - }); + if (!userId || !item.isEditable) { + return item.price.toNumber(); + } - const price = - userId && isEditable - ? ( - await this.prisma.userItem.findUniqueOrThrow({ - where: { - userId_itemId: { - itemId, - userId, - }, - }, - }) - ).price - : defaultPrice; - - return price.toNumber(); - } + const userItem = await this.prisma.userItem.findUniqueOrThrow({ + where: { userId_itemId: { itemId, userId } }, + }); - getUserId() { - return this.context.req?.user?.id; + return userItem.price.toNumber(); } - async validateUserItem(itemId: number) { - const userId = this.getUserId(); - + async validateUserItem(itemId: number, userId: number) { const userItem = await this.prisma.userItem.findUnique({ where: { id: itemId, userId }, }); @@ -282,4 +221,20 @@ export class UserContentService { return true; } + + private async getUserOverride( + userId: number | undefined, + fetchDefault: () => Promise, + fetchUserOverride: (userId: number) => Promise, + extractValue: (source: TDefault | TUser) => TValue + ): Promise { + const defaultEntity = await fetchDefault(); + + if (!userId) { + return extractValue(defaultEntity); + } + + const userEntity = await fetchUserOverride(userId); + return userEntity ? extractValue(userEntity) : extractValue(defaultEntity); + } } diff --git a/src/backend/src/user/service/user-gold-exchange-rate.service.spec.ts b/src/backend/src/user/service/user-gold-exchange-rate.service.spec.ts index 346436b1..6814e762 100644 --- a/src/backend/src/user/service/user-gold-exchange-rate.service.spec.ts +++ b/src/backend/src/user/service/user-gold-exchange-rate.service.spec.ts @@ -1,6 +1,5 @@ import { Test, TestingModule } from "@nestjs/testing"; import { PrismaService } from "src/prisma"; -import { CONTEXT } from "@nestjs/graphql"; import { UserGoldExchangeRateService } from "src/user/service/user-gold-exchange-rate.service"; import { UserFactory } from "src/test/factory/user.factory"; import { User } from "@prisma/client"; @@ -13,15 +12,7 @@ describe("UserGoldExchangeRateService", () => { beforeAll(async () => { module = await Test.createTestingModule({ - providers: [ - PrismaService, - UserGoldExchangeRateService, - UserFactory, - { - provide: CONTEXT, - useValue: { req: { user: { id: undefined } } }, - }, - ], + providers: [PrismaService, UserGoldExchangeRateService, UserFactory], }).compile(); service = module.get(UserGoldExchangeRateService); @@ -40,7 +31,6 @@ describe("UserGoldExchangeRateService", () => { beforeAll(async () => { user = await userFactory.create(); - service["context"].req.user = { id: user.id }; await prisma.goldExchangeRate.create({ data: { @@ -60,7 +50,7 @@ describe("UserGoldExchangeRateService", () => { describe("getGoldExchangeRate", () => { it("basic", async () => { - const result = await service.getGoldExchangeRate(); + const result = await service.getGoldExchangeRate(user.id); expect(result.goldAmount).toEqual(userGoldAmount); expect(result.krwAmount).toEqual(userKrwAmount); @@ -72,10 +62,6 @@ describe("UserGoldExchangeRateService", () => { const goldAmount = 100; const krwAmount = 25; - beforeAll(async () => { - service["context"].req.user = { id: undefined }; - }); - describe("getGoldExchangeRate", () => { beforeAll(async () => { await prisma.goldExchangeRate.create({ diff --git a/src/backend/src/user/service/user-gold-exchange-rate.service.ts b/src/backend/src/user/service/user-gold-exchange-rate.service.ts index 78f2ea13..c9768dc0 100644 --- a/src/backend/src/user/service/user-gold-exchange-rate.service.ts +++ b/src/backend/src/user/service/user-gold-exchange-rate.service.ts @@ -1,20 +1,11 @@ -import { Inject, Injectable, UseGuards } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; import { PrismaService } from "../../prisma"; -import { CONTEXT } from "@nestjs/graphql"; -import { ContextType } from "./types"; -import { AuthGuard } from "src/auth/auth.guard"; -@UseGuards(AuthGuard) @Injectable() export class UserGoldExchangeRateService { - constructor( - private readonly prisma: PrismaService, - @Inject(CONTEXT) private context: ContextType - ) {} - - async getGoldExchangeRate() { - const userId = this.getUserId(); + constructor(private readonly prisma: PrismaService) {} + async getGoldExchangeRate(userId?: number) { const goldExchangeRate = await this.prisma.goldExchangeRate.findFirstOrThrow(); if (userId) { @@ -29,8 +20,4 @@ export class UserGoldExchangeRateService { return goldExchangeRate; } - - private getUserId() { - return this.context.req?.user?.id; - } } diff --git a/src/backend/src/user/user.module.ts b/src/backend/src/user/user.module.ts index 30fa98f4..f42122cf 100644 --- a/src/backend/src/user/user.module.ts +++ b/src/backend/src/user/user.module.ts @@ -1,9 +1,9 @@ import { Module } from "@nestjs/common"; import { PrismaModule } from "src/prisma"; -import { UserListQuery } from "./query/user-list.query"; +import { UserResolver } from "./user.resolver"; @Module({ imports: [PrismaModule], - providers: [UserListQuery], + providers: [UserResolver], }) export class UserModule {} diff --git a/src/backend/src/user/query/user-list.query.ts b/src/backend/src/user/user.resolver.ts similarity index 91% rename from src/backend/src/user/query/user-list.query.ts rename to src/backend/src/user/user.resolver.ts index 4d54f690..9f4e7fed 100644 --- a/src/backend/src/user/query/user-list.query.ts +++ b/src/backend/src/user/user.resolver.ts @@ -1,9 +1,9 @@ import { Query, Resolver } from "@nestjs/graphql"; -import { PrismaService } from "src/prisma"; import { User } from "src/common/object/user.object"; +import { PrismaService } from "src/prisma"; @Resolver() -export class UserListQuery { +export class UserResolver { constructor(private prisma: PrismaService) {} @Query(() => [User]) diff --git a/src/frontend/src/core/graphql/generated.tsx b/src/frontend/src/core/graphql/generated.tsx index 85e63dbb..0c732d3f 100644 --- a/src/frontend/src/core/graphql/generated.tsx +++ b/src/frontend/src/core/graphql/generated.tsx @@ -29,7 +29,9 @@ export type AuctionItem = { }; export type AuctionItemListFilter = { + /** 가격 통계 수집 활성화 여부 */ isStatScraperEnabled?: InputMaybe; + /** 아이템 이름 검색 키워드 */ nameKeyword?: InputMaybe; }; @@ -39,9 +41,38 @@ export enum AuthProvider { KAKAO = 'KAKAO' } +export type CalculateCustomContentWageInput = { + /** 아이템 목록 */ + items: Array; + /** 분 */ + minutes: Scalars['Int']['input']; + /** 초 */ + seconds: Scalars['Int']['input']; +}; + +export type CalculateCustomContentWageItemInput = { + /** 아이템 ID */ + id: Scalars['Int']['input']; + /** 획득 수량 */ + quantity: Scalars['Float']['input']; +}; + +export type CalculateCustomContentWageResult = { + __typename?: 'CalculateCustomContentWageResult'; + /** 회당 골드 획득량 */ + goldAmountPerClear: Scalars['Int']['output']; + /** 시급 (골드) */ + goldAmountPerHour: Scalars['Int']['output']; + /** 시급 (원화) */ + krwAmountPerHour: Scalars['Int']['output']; + /** 성공 여부 */ + ok: Scalars['Boolean']['output']; +}; + export type Content = { __typename?: 'Content'; contentCategory: ContentCategory; + /** 컨텐츠 카테고리 ID */ contentCategoryId: Scalars['Int']['output']; contentRewards: Array; contentSeeMoreRewards: Array; @@ -49,12 +80,16 @@ export type Content = { displayName: Scalars['String']['output']; duration: Scalars['Int']['output']; durationText: Scalars['String']['output']; + /** 관문 번호 */ gate?: Maybe; id: Scalars['Int']['output']; + /** 레벨 */ level: Scalars['Int']['output']; + /** 컨텐츠 이름 */ name: Scalars['String']['output']; updatedAt: Scalars['DateTime']['output']; wage: ContentWage; + /** 시급 계산 필터 */ wageFilter?: Maybe; }; @@ -72,32 +107,6 @@ export type ContentCategory = { updatedAt: Scalars['DateTime']['output']; }; -export type ContentCreateInput = { - categoryId: Scalars['Int']['input']; - contentRewards: Array; - contentSeeMoreRewards?: InputMaybe>; - duration: Scalars['Int']['input']; - gate?: InputMaybe; - level: Scalars['Int']['input']; - name: Scalars['String']['input']; -}; - -export type ContentCreateItemsInput = { - averageQuantity: Scalars['Float']['input']; - isBound: Scalars['Boolean']['input']; - itemId: Scalars['Int']['input']; -}; - -export type ContentCreateResult = { - __typename?: 'ContentCreateResult'; - ok: Scalars['Boolean']['output']; -}; - -export type ContentCreateSeeMoreRewardsInput = { - itemId: Scalars['Int']['input']; - quantity: Scalars['Float']['input']; -}; - export type ContentDuration = { __typename?: 'ContentDuration'; content: Content; @@ -108,32 +117,6 @@ export type ContentDuration = { value: Scalars['Int']['output']; }; -export type ContentDurationEditInput = { - contentId: Scalars['Int']['input']; - minutes: Scalars['Int']['input']; - seconds: Scalars['Int']['input']; -}; - -export type ContentDurationEditResult = { - __typename?: 'ContentDurationEditResult'; - ok: Scalars['Boolean']['output']; -}; - -export type ContentDurationsEditInput = { - contentDurations: Array; -}; - -export type ContentDurationsEditInputDuration = { - contentId: Scalars['Int']['input']; - minutes: Scalars['Int']['input']; - seconds: Scalars['Int']['input']; -}; - -export type ContentDurationsEditResult = { - __typename?: 'ContentDurationsEditResult'; - ok: Scalars['Boolean']['output']; -}; - export type ContentGroup = { __typename?: 'ContentGroup'; contentCategory: ContentCategory; @@ -147,6 +130,7 @@ export type ContentGroup = { }; export type ContentGroupFilter = { + /** 그룹화할 컨텐츠 ID 목록 */ contentIds?: InputMaybe>; }; @@ -159,26 +143,39 @@ export type ContentGroupWage = { }; export type ContentGroupWageListFilter = { + /** 필터링할 컨텐츠 카테고리 ID */ contentCategoryId?: InputMaybe; - includeIsBound?: InputMaybe; - includeIsSeeMore?: InputMaybe; + /** 귀속 아이템 포함 여부 */ + includeBound?: InputMaybe; + /** 포함할 아이템 ID 목록 */ includeItemIds?: InputMaybe>; + /** 추가 보상(더보기) 포함 여부 */ + includeSeeMore?: InputMaybe; + /** 검색 키워드 */ keyword?: InputMaybe; + /** 컨텐츠 상태 */ status?: InputMaybe; }; export type ContentListFilter = { + /** 필터링할 컨텐츠 카테고리 ID */ contentCategoryId?: InputMaybe; - includeIsSeeMore?: InputMaybe; + /** 추가 보상(더보기) 포함 여부 */ + includeSeeMore?: InputMaybe; + /** 검색 키워드 */ keyword?: InputMaybe; + /** 컨텐츠 상태 */ status?: InputMaybe; }; export type ContentObjectWageFilter = { __typename?: 'ContentObjectWageFilter'; - includeIsBound?: Maybe; - includeIsSeeMore?: Maybe; + /** 귀속 아이템 포함 여부 */ + includeBound?: Maybe; + /** 포함할 아이템 ID 목록 */ includeItemIds?: Maybe>; + /** 추가 보상(더보기) 포함 여부 */ + includeSeeMore?: Maybe; }; export type ContentReward = { @@ -192,37 +189,6 @@ export type ContentReward = { updatedAt: Scalars['DateTime']['output']; }; -export type ContentRewardEditInput = { - averageQuantity: Scalars['Float']['input']; - contentId: Scalars['Int']['input']; - isSellable: Scalars['Boolean']['input']; - itemId: Scalars['Int']['input']; -}; - -export type ContentRewardReportInput = { - averageQuantity: Scalars['Float']['input']; - id: Scalars['Int']['input']; -}; - -export type ContentRewardsEditInput = { - contentRewards: Array; - isReportable: Scalars['Boolean']['input']; -}; - -export type ContentRewardsEditResult = { - __typename?: 'ContentRewardsEditResult'; - ok: Scalars['Boolean']['output']; -}; - -export type ContentRewardsReportInput = { - contentRewards: Array; -}; - -export type ContentRewardsReportResult = { - __typename?: 'ContentRewardsReportResult'; - ok: Scalars['Boolean']['output']; -}; - export type ContentSeeMoreReward = { __typename?: 'ContentSeeMoreReward'; contentId: Scalars['Int']['output']; @@ -234,21 +200,6 @@ export type ContentSeeMoreReward = { updatedAt: Scalars['DateTime']['output']; }; -export type ContentSeeMoreRewardEditInput = { - contentId: Scalars['Int']['input']; - itemId: Scalars['Int']['input']; - quantity: Scalars['Float']['input']; -}; - -export type ContentSeeMoreRewardsEditInput = { - contentSeeMoreRewards: Array; -}; - -export type ContentSeeMoreRewardsEditResult = { - __typename?: 'ContentSeeMoreRewardsEditResult'; - ok: Scalars['Boolean']['output']; -}; - export enum ContentStatus { ACTIVE = 'ACTIVE', ARCHIVED = 'ARCHIVED' @@ -264,40 +215,173 @@ export type ContentWage = { }; export type ContentWageFilter = { - includeIsBound?: InputMaybe; - includeIsSeeMore?: InputMaybe; + /** 귀속 아이템 포함 여부 */ + includeBound?: InputMaybe; + /** 포함할 아이템 ID 목록 */ includeItemIds?: InputMaybe>; + /** 추가 보상(더보기) 포함 여부 */ + includeSeeMore?: InputMaybe; }; export type ContentWageListFilter = { + /** 필터링할 컨텐츠 카테고리 ID */ contentCategoryId?: InputMaybe; - includeIsBound?: InputMaybe; - includeIsSeeMore?: InputMaybe; + /** 귀속 아이템 포함 여부 */ + includeBound?: InputMaybe; + /** 포함할 아이템 ID 목록 */ includeItemIds?: InputMaybe>; + /** 추가 보상(더보기) 포함 여부 */ + includeSeeMore?: InputMaybe; + /** 검색 키워드 */ keyword?: InputMaybe; + /** 컨텐츠 상태 */ status?: InputMaybe; }; export type ContentsFilter = { + /** 필터링할 컨텐츠 ID 목록 */ ids?: InputMaybe>; }; -export type CustomContentWageCalculateInput = { - items: Array; +export type CreateContentInput = { + /** 컨텐츠 카테고리 ID */ + categoryId: Scalars['Int']['input']; + /** 컨텐츠 보상 아이템 목록 */ + contentRewards: Array; + /** 추가 보상(더보기) 아이템 목록 */ + contentSeeMoreRewards?: InputMaybe>; + /** 소요 시간 (초) */ + duration: Scalars['Int']['input']; + /** 관문 번호 */ + gate?: InputMaybe; + /** 레벨 */ + level: Scalars['Int']['input']; + /** 컨텐츠 이름 */ + name: Scalars['String']['input']; +}; + +export type CreateContentItemInput = { + /** 평균 획득 수량 */ + averageQuantity: Scalars['Float']['input']; + /** 귀속 여부 */ + isBound: Scalars['Boolean']['input']; + /** 아이템 ID */ + itemId: Scalars['Int']['input']; +}; + +export type CreateContentResult = { + __typename?: 'CreateContentResult'; + /** 성공 여부 */ + ok: Scalars['Boolean']['output']; +}; + +export type CreateContentSeeMoreRewardInput = { + /** 아이템 ID */ + itemId: Scalars['Int']['input']; + /** 획득 수량 */ + quantity: Scalars['Float']['input']; +}; + +export type EditContentDurationInput = { + /** 컨텐츠 ID */ + contentId: Scalars['Int']['input']; + /** 분 */ minutes: Scalars['Int']['input']; + /** 초 */ seconds: Scalars['Int']['input']; }; -export type CustomContentWageCalculateItemsInput = { - id: Scalars['Int']['input']; +export type EditContentDurationResult = { + __typename?: 'EditContentDurationResult'; + /** 성공 여부 */ + ok: Scalars['Boolean']['output']; +}; + +export type EditContentDurationsDurationInput = { + /** 컨텐츠 ID */ + contentId: Scalars['Int']['input']; + /** 분 */ + minutes: Scalars['Int']['input']; + /** 초 */ + seconds: Scalars['Int']['input']; +}; + +export type EditContentDurationsInput = { + /** 수정할 컨텐츠 소요 시간 목록 */ + contentDurations: Array; +}; + +export type EditContentDurationsResult = { + __typename?: 'EditContentDurationsResult'; + /** 성공 여부 */ + ok: Scalars['Boolean']['output']; +}; + +export type EditContentRewardInput = { + /** 평균 획득 수량 */ + averageQuantity: Scalars['Float']['input']; + /** 컨텐츠 ID */ + contentId: Scalars['Int']['input']; + /** 거래 가능 여부 */ + isSellable: Scalars['Boolean']['input']; + /** 아이템 ID */ + itemId: Scalars['Int']['input']; +}; + +export type EditContentRewardsInput = { + /** 수정할 컨텐츠 보상 목록 */ + contentRewards: Array; + /** 제보 가능 여부 */ + isReportable: Scalars['Boolean']['input']; +}; + +export type EditContentRewardsResult = { + __typename?: 'EditContentRewardsResult'; + /** 성공 여부 */ + ok: Scalars['Boolean']['output']; +}; + +export type EditContentSeeMoreRewardInput = { + /** 컨텐츠 ID */ + contentId: Scalars['Int']['input']; + /** 아이템 ID */ + itemId: Scalars['Int']['input']; + /** 획득 수량 */ quantity: Scalars['Float']['input']; }; -export type CustomContentWageCalculateResult = { - __typename?: 'CustomContentWageCalculateResult'; - goldAmountPerClear: Scalars['Int']['output']; - goldAmountPerHour: Scalars['Int']['output']; - krwAmountPerHour: Scalars['Int']['output']; +export type EditContentSeeMoreRewardsInput = { + /** 수정할 추가 보상(더보기) 목록 */ + contentSeeMoreRewards: Array; +}; + +export type EditContentSeeMoreRewardsResult = { + __typename?: 'EditContentSeeMoreRewardsResult'; + /** 성공 여부 */ + ok: Scalars['Boolean']['output']; +}; + +export type EditGoldExchangeRateInput = { + /** 100골드당 원화 금액 */ + krwAmount: Scalars['Int']['input']; +}; + +export type EditGoldExchangeRateResult = { + __typename?: 'EditGoldExchangeRateResult'; + /** 성공 여부 */ + ok: Scalars['Boolean']['output']; +}; + +export type EditUserItemPriceInput = { + /** 아이템 ID */ + id: Scalars['Int']['input']; + /** 사용자 지정 가격 */ + price: Scalars['Float']['input']; +}; + +export type EditUserItemPriceResult = { + __typename?: 'EditUserItemPriceResult'; + /** 성공 여부 */ ok: Scalars['Boolean']['output']; }; @@ -310,15 +394,6 @@ export type GoldExchangeRate = { updatedAt: Scalars['DateTime']['output']; }; -export type GoldExchangeRateEditInput = { - krwAmount: Scalars['Int']['input']; -}; - -export type GoldExchangeRateEditResult = { - __typename?: 'GoldExchangeRateEditResult'; - ok: Scalars['Boolean']['output']; -}; - export type Item = { __typename?: 'Item'; createdAt: Scalars['DateTime']['output']; @@ -338,7 +413,9 @@ export enum ItemKind { } export type ItemsFilter = { + /** 제외할 아이템 이름 */ excludeItemName?: InputMaybe; + /** 아이템 종류 */ kind?: InputMaybe; }; @@ -358,72 +435,78 @@ export type MarketItem = { }; export type MarketItemListFilter = { + /** 카테고리 이름 */ categoryName?: InputMaybe; + /** 등급 */ grade?: InputMaybe; + /** 가격 통계 수집 활성화 여부 */ isStatScraperEnabled?: InputMaybe; + /** 검색 키워드 */ keyword?: InputMaybe; }; export type Mutation = { __typename?: 'Mutation'; - contentCreate: ContentCreateResult; - contentDurationEdit: ContentDurationEditResult; - contentDurationsEdit: ContentDurationsEditResult; - contentRewardsEdit: ContentRewardsEditResult; - contentRewardsReport: ContentRewardsReportResult; - contentSeeMoreRewardsEdit: ContentSeeMoreRewardsEditResult; - customContentWageCalculate: CustomContentWageCalculateResult; - goldExchangeRateEdit: GoldExchangeRateEditResult; - userItemPriceEdit: UserItemPriceEditResult; + contentCreate: CreateContentResult; + contentDurationEdit: EditContentDurationResult; + contentDurationsEdit: EditContentDurationsResult; + contentRewardsEdit: EditContentRewardsResult; + contentRewardsReport: ReportContentRewardsResult; + contentSeeMoreRewardsEdit: EditContentSeeMoreRewardsResult; + customContentWageCalculate: CalculateCustomContentWageResult; + goldExchangeRateEdit: EditGoldExchangeRateResult; + userItemPriceEdit: EditUserItemPriceResult; }; export type MutationContentCreateArgs = { - input: ContentCreateInput; + input: CreateContentInput; }; export type MutationContentDurationEditArgs = { - input: ContentDurationEditInput; + input: EditContentDurationInput; }; export type MutationContentDurationsEditArgs = { - input: ContentDurationsEditInput; + input: EditContentDurationsInput; }; export type MutationContentRewardsEditArgs = { - input: ContentRewardsEditInput; + input: EditContentRewardsInput; }; export type MutationContentRewardsReportArgs = { - input: ContentRewardsReportInput; + input: ReportContentRewardsInput; }; export type MutationContentSeeMoreRewardsEditArgs = { - input: ContentSeeMoreRewardsEditInput; + input: EditContentSeeMoreRewardsInput; }; export type MutationCustomContentWageCalculateArgs = { - input: CustomContentWageCalculateInput; + input: CalculateCustomContentWageInput; }; export type MutationGoldExchangeRateEditArgs = { - input: GoldExchangeRateEditInput; + input: EditGoldExchangeRateInput; }; export type MutationUserItemPriceEditArgs = { - input: UserItemPriceEditInput; + input: EditUserItemPriceInput; }; export type OrderByArg = { + /** 정렬 필드명 */ field: Scalars['String']['input']; + /** 정렬 방향 (asc 또는 desc) */ order: Scalars['String']['input']; }; @@ -482,6 +565,7 @@ export type QueryContentGroupWageListArgs = { export type QueryContentListArgs = { filter?: InputMaybe; + orderBy?: InputMaybe>; }; @@ -516,6 +600,24 @@ export type QueryMarketItemsArgs = { take?: InputMaybe; }; +export type ReportContentRewardInput = { + /** 평균 획득 수량 */ + averageQuantity: Scalars['Float']['input']; + /** 컨텐츠 보상 ID */ + id: Scalars['Int']['input']; +}; + +export type ReportContentRewardsInput = { + /** 제보할 컨텐츠 보상 목록 */ + contentRewards: Array; +}; + +export type ReportContentRewardsResult = { + __typename?: 'ReportContentRewardsResult'; + /** 성공 여부 */ + ok: Scalars['Boolean']['output']; +}; + export type User = { __typename?: 'User'; createdAt: Scalars['DateTime']['output']; @@ -539,16 +641,6 @@ export type UserItem = { userId: Scalars['Int']['output']; }; -export type UserItemPriceEditInput = { - id: Scalars['Int']['input']; - price: Scalars['Float']['input']; -}; - -export type UserItemPriceEditResult = { - __typename?: 'UserItemPriceEditResult'; - ok: Scalars['Boolean']['output']; -}; - export enum UserRole { ADMIN = 'ADMIN', OWNER = 'OWNER', @@ -561,11 +653,11 @@ export type ContentCreateTabDataQueryVariables = Exact<{ [key: string]: never; } export type ContentCreateTabDataQuery = { __typename?: 'Query', contentCategories: Array<{ __typename?: 'ContentCategory', id: number, name: string }>, items: Array<{ __typename?: 'Item', id: number, name: string }> }; export type ContentCreateTabMutationVariables = Exact<{ - input: ContentCreateInput; + input: CreateContentInput; }>; -export type ContentCreateTabMutation = { __typename?: 'Mutation', contentCreate: { __typename?: 'ContentCreateResult', ok: boolean } }; +export type ContentCreateTabMutation = { __typename?: 'Mutation', contentCreate: { __typename?: 'CreateContentResult', ok: boolean } }; export type PredictRewardsTabQueryVariables = Exact<{ [key: string]: never; }>; @@ -614,11 +706,11 @@ export type CustomContentWageCalculateDialogQueryQueryVariables = Exact<{ [key: export type CustomContentWageCalculateDialogQueryQuery = { __typename?: 'Query', items: Array<{ __typename?: 'Item', id: number, name: string }> }; export type CustomContentWageCalculateDialogMutationMutationVariables = Exact<{ - input: CustomContentWageCalculateInput; + input: CalculateCustomContentWageInput; }>; -export type CustomContentWageCalculateDialogMutationMutation = { __typename?: 'Mutation', customContentWageCalculate: { __typename?: 'CustomContentWageCalculateResult', krwAmountPerHour: number, goldAmountPerHour: number, goldAmountPerClear: number, ok: boolean } }; +export type CustomContentWageCalculateDialogMutationMutation = { __typename?: 'Mutation', customContentWageCalculate: { __typename?: 'CalculateCustomContentWageResult', krwAmountPerHour: number, goldAmountPerHour: number, goldAmountPerClear: number, ok: boolean } }; export type GoldExchangeRateSettingDialogQueryVariables = Exact<{ [key: string]: never; }>; @@ -626,11 +718,11 @@ export type GoldExchangeRateSettingDialogQueryVariables = Exact<{ [key: string]: export type GoldExchangeRateSettingDialogQuery = { __typename?: 'Query', goldExchangeRate: { __typename?: 'GoldExchangeRate', id: number, krwAmount: number, goldAmount: number } }; export type GoldExchangeRateEditMutationVariables = Exact<{ - input: GoldExchangeRateEditInput; + input: EditGoldExchangeRateInput; }>; -export type GoldExchangeRateEditMutation = { __typename?: 'Mutation', goldExchangeRateEdit: { __typename?: 'GoldExchangeRateEditResult', ok: boolean } }; +export type GoldExchangeRateEditMutation = { __typename?: 'Mutation', goldExchangeRateEdit: { __typename?: 'EditGoldExchangeRateResult', ok: boolean } }; export type ContentWageListQueryVariables = Exact<{ [key: string]: never; }>; @@ -661,11 +753,11 @@ export type UserExtraItemPriceEditDialogQueryVariables = Exact<{ export type UserExtraItemPriceEditDialogQuery = { __typename?: 'Query', item: { __typename?: 'Item', name: string, userItem: { __typename?: 'UserItem', id: number, price: number } } }; export type UserItemPriceEditMutationVariables = Exact<{ - input: UserItemPriceEditInput; + input: EditUserItemPriceInput; }>; -export type UserItemPriceEditMutation = { __typename?: 'Mutation', userItemPriceEdit: { __typename?: 'UserItemPriceEditResult', ok: boolean } }; +export type UserItemPriceEditMutation = { __typename?: 'Mutation', userItemPriceEdit: { __typename?: 'EditUserItemPriceResult', ok: boolean } }; export type MarketItemListTableQueryVariables = Exact<{ filter?: InputMaybe; @@ -704,11 +796,11 @@ export type ContentDurationEditDialogQueryVariables = Exact<{ export type ContentDurationEditDialogQuery = { __typename?: 'Query', content: { __typename?: 'Content', displayName: string, duration: number } }; export type ContentDurationEditMutationVariables = Exact<{ - input: ContentDurationEditInput; + input: EditContentDurationInput; }>; -export type ContentDurationEditMutation = { __typename?: 'Mutation', contentDurationEdit: { __typename?: 'ContentDurationEditResult', ok: boolean } }; +export type ContentDurationEditMutation = { __typename?: 'Mutation', contentDurationEdit: { __typename?: 'EditContentDurationResult', ok: boolean } }; export type ContentGroupDetailsDialogQueryVariables = Exact<{ contentIds: Array | Scalars['Int']['input']; @@ -725,11 +817,11 @@ export type ContentGroupDurationEditDialogQueryVariables = Exact<{ export type ContentGroupDurationEditDialogQuery = { __typename?: 'Query', contentGroup: { __typename?: 'ContentGroup', name: string }, contents: Array<{ __typename?: 'Content', duration: number, gate?: number | null, id: number }> }; export type ContentDurationsEditMutationVariables = Exact<{ - input: ContentDurationsEditInput; + input: EditContentDurationsInput; }>; -export type ContentDurationsEditMutation = { __typename?: 'Mutation', contentDurationsEdit: { __typename?: 'ContentDurationsEditResult', ok: boolean } }; +export type ContentDurationsEditMutation = { __typename?: 'Mutation', contentDurationsEdit: { __typename?: 'EditContentDurationsResult', ok: boolean } }; export type ContentRewardEditDialogQueryVariables = Exact<{ id: Scalars['Int']['input']; @@ -739,11 +831,11 @@ export type ContentRewardEditDialogQueryVariables = Exact<{ export type ContentRewardEditDialogQuery = { __typename?: 'Query', content: { __typename?: 'Content', displayName: string, contentRewards: Array<{ __typename?: 'ContentReward', averageQuantity: number, id: number, isSellable: boolean, item: { __typename?: 'Item', id: number, name: string } }> } }; export type ContentRewardsEditMutationVariables = Exact<{ - input: ContentRewardsEditInput; + input: EditContentRewardsInput; }>; -export type ContentRewardsEditMutation = { __typename?: 'Mutation', contentRewardsEdit: { __typename?: 'ContentRewardsEditResult', ok: boolean } }; +export type ContentRewardsEditMutation = { __typename?: 'Mutation', contentRewardsEdit: { __typename?: 'EditContentRewardsResult', ok: boolean } }; export type ContentRewardReportDialogQueryVariables = Exact<{ id: Scalars['Int']['input']; @@ -753,11 +845,11 @@ export type ContentRewardReportDialogQueryVariables = Exact<{ export type ContentRewardReportDialogQuery = { __typename?: 'Query', content: { __typename?: 'Content', contentRewards: Array<{ __typename?: 'ContentReward', averageQuantity: number, id: number, isSellable: boolean, item: { __typename?: 'Item', name: string } }> } }; export type ContentRewardsReportMutationVariables = Exact<{ - input: ContentRewardsReportInput; + input: ReportContentRewardsInput; }>; -export type ContentRewardsReportMutation = { __typename?: 'Mutation', contentRewardsReport: { __typename?: 'ContentRewardsReportResult', ok: boolean } }; +export type ContentRewardsReportMutation = { __typename?: 'Mutation', contentRewardsReport: { __typename?: 'ReportContentRewardsResult', ok: boolean } }; export type ContentSeeMoreRewardEditDialogQueryVariables = Exact<{ id: Scalars['Int']['input']; @@ -767,11 +859,11 @@ export type ContentSeeMoreRewardEditDialogQueryVariables = Exact<{ export type ContentSeeMoreRewardEditDialogQuery = { __typename?: 'Query', content: { __typename?: 'Content', displayName: string, contentSeeMoreRewards: Array<{ __typename?: 'ContentSeeMoreReward', id: number, quantity: number, item: { __typename?: 'Item', id: number, name: string } }> } }; export type ContentSeeMoreRewardsEditMutationVariables = Exact<{ - input: ContentSeeMoreRewardsEditInput; + input: EditContentSeeMoreRewardsInput; }>; -export type ContentSeeMoreRewardsEditMutation = { __typename?: 'Mutation', contentSeeMoreRewardsEdit: { __typename?: 'ContentSeeMoreRewardsEditResult', ok: boolean } }; +export type ContentSeeMoreRewardsEditMutation = { __typename?: 'Mutation', contentSeeMoreRewardsEdit: { __typename?: 'EditContentSeeMoreRewardsResult', ok: boolean } }; export type ItemStatUpdateToggleTipQueryVariables = Exact<{ take?: InputMaybe; @@ -783,7 +875,7 @@ export type ItemStatUpdateToggleTipQuery = { __typename?: 'Query', marketItems: export const ContentCreateTabDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ContentCreateTabData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentCategories"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; -export const ContentCreateTabDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ContentCreateTab"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ContentCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const ContentCreateTabDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ContentCreateTab"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateContentInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const PredictRewardsTabDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PredictRewardsTab"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentCategories"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const UserListTabQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserListTabQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"refId"}}]}}]}}]} as unknown as DocumentNode; export const ValidateRewardsTabDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidateRewardsTab"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"level"}},{"kind":"Field","name":{"kind":"Name","value":"contentCategory"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; @@ -792,27 +884,27 @@ export const ContentGroupWageListTableDocument = {"kind":"Document","definitions export const ContentWageListTableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ContentWageListTable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ContentWageListFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentWageList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentCategory"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"durationText"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"level"}}]}},{"kind":"Field","name":{"kind":"Name","value":"goldAmountPerHour"}},{"kind":"Field","name":{"kind":"Name","value":"goldAmountPerClear"}},{"kind":"Field","name":{"kind":"Name","value":"krwAmountPerHour"}}]}}]}}]} as unknown as DocumentNode; export const ItemsFilterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ItemsFilter"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const CustomContentWageCalculateDialogQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CustomContentWageCalculateDialogQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; -export const CustomContentWageCalculateDialogMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CustomContentWageCalculateDialogMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CustomContentWageCalculateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customContentWageCalculate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"krwAmountPerHour"}},{"kind":"Field","name":{"kind":"Name","value":"goldAmountPerHour"}},{"kind":"Field","name":{"kind":"Name","value":"goldAmountPerClear"}},{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const CustomContentWageCalculateDialogMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CustomContentWageCalculateDialogMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CalculateCustomContentWageInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customContentWageCalculate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"krwAmountPerHour"}},{"kind":"Field","name":{"kind":"Name","value":"goldAmountPerHour"}},{"kind":"Field","name":{"kind":"Name","value":"goldAmountPerClear"}},{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const GoldExchangeRateSettingDialogDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GoldExchangeRateSettingDialog"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"goldExchangeRate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"krwAmount"}},{"kind":"Field","name":{"kind":"Name","value":"goldAmount"}}]}}]}}]} as unknown as DocumentNode; -export const GoldExchangeRateEditDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"GoldExchangeRateEdit"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"GoldExchangeRateEditInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"goldExchangeRateEdit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const GoldExchangeRateEditDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"GoldExchangeRateEdit"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EditGoldExchangeRateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"goldExchangeRateEdit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const ContentWageListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ContentWageList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"goldExchangeRate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"goldAmount"}},{"kind":"Field","name":{"kind":"Name","value":"krwAmount"}}]}}]}}]} as unknown as DocumentNode; export const AuctionItemListTableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AuctionItemListTable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"AuctionItemListFilter"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"take"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"OrderByArg"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"auctionItemList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avgBuyPrice"}},{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"auctionItems"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"take"},"value":{"kind":"Variable","name":{"kind":"Name","value":"take"}}},{"kind":"Argument","name":{"kind":"Name","value":"orderBy"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; export const ExtraItemListTableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ExtraItemListTable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ItemsFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"price"}}]}}]}}]} as unknown as DocumentNode; export const UserExtraItemPriceEditDialogDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserExtraItemPriceEditDialog"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"item"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"userItem"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"price"}}]}}]}}]}}]} as unknown as DocumentNode; -export const UserItemPriceEditDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UserItemPriceEdit"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UserItemPriceEditInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userItemPriceEdit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const UserItemPriceEditDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UserItemPriceEdit"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EditUserItemPriceInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userItemPriceEdit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const MarketItemListTableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MarketItemListTable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"MarketItemListFilter"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"take"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"OrderByArg"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"marketItemList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bundleCount"}},{"kind":"Field","name":{"kind":"Name","value":"currentMinPrice"}},{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"recentPrice"}},{"kind":"Field","name":{"kind":"Name","value":"yDayAvgPrice"}}]}},{"kind":"Field","name":{"kind":"Name","value":"marketItems"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"take"},"value":{"kind":"Variable","name":{"kind":"Name","value":"take"}}},{"kind":"Argument","name":{"kind":"Name","value":"orderBy"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; export const ContentCategoriesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ContentCategories"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentCategories"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const ContentDetailsDialogDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ContentDetailsDialog"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"content"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contentId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentCategory"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"contentRewards"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"averageQuantity"}},{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isSellable"}}]}},{"kind":"Field","name":{"kind":"Name","value":"contentSeeMoreRewards"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"level"}}]}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const ContentDetailsDialogWageSectionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ContentDetailsDialogWageSection"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ContentWageFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"content"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contentId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"durationText"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"wage"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"krwAmountPerHour"}},{"kind":"Field","name":{"kind":"Name","value":"goldAmountPerHour"}},{"kind":"Field","name":{"kind":"Name","value":"goldAmountPerClear"}}]}}]}}]}}]} as unknown as DocumentNode; export const ContentDurationEditDialogDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ContentDurationEditDialog"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"content"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"duration"}}]}}]}}]} as unknown as DocumentNode; -export const ContentDurationEditDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ContentDurationEdit"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ContentDurationEditInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentDurationEdit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const ContentDurationEditDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ContentDurationEdit"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EditContentDurationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentDurationEdit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const ContentGroupDetailsDialogDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ContentGroupDetailsDialog"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contentIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentGroup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"contentIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contentIds"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"gate"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const ContentGroupDurationEditDialogDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ContentGroupDurationEditDialog"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentGroup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"contentIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"contents"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"duration"}},{"kind":"Field","name":{"kind":"Name","value":"gate"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; -export const ContentDurationsEditDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ContentDurationsEdit"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ContentDurationsEditInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentDurationsEdit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const ContentDurationsEditDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ContentDurationsEdit"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EditContentDurationsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentDurationsEdit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const ContentRewardEditDialogDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ContentRewardEditDialog"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"content"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentRewards"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"averageQuantity"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isSellable"}}]}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}}]}}]}}]} as unknown as DocumentNode; -export const ContentRewardsEditDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ContentRewardsEdit"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ContentRewardsEditInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentRewardsEdit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const ContentRewardsEditDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ContentRewardsEdit"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EditContentRewardsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentRewardsEdit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const ContentRewardReportDialogDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ContentRewardReportDialog"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"content"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentRewards"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"averageQuantity"}},{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isSellable"}}]}}]}}]}}]} as unknown as DocumentNode; -export const ContentRewardsReportDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ContentRewardsReport"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ContentRewardsReportInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentRewardsReport"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const ContentRewardsReportDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ContentRewardsReport"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ReportContentRewardsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentRewardsReport"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const ContentSeeMoreRewardEditDialogDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ContentSeeMoreRewardEditDialog"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"content"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentSeeMoreRewards"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}}]}}]}}]} as unknown as DocumentNode; -export const ContentSeeMoreRewardsEditDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ContentSeeMoreRewardsEdit"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ContentSeeMoreRewardsEditInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentSeeMoreRewardsEdit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const ContentSeeMoreRewardsEditDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ContentSeeMoreRewardsEdit"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EditContentSeeMoreRewardsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentSeeMoreRewardsEdit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const ItemStatUpdateToggleTipDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ItemStatUpdateToggleTip"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"take"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"OrderByArg"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"marketItems"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"take"},"value":{"kind":"Variable","name":{"kind":"Name","value":"take"}}},{"kind":"Argument","name":{"kind":"Name","value":"orderBy"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"auctionItems"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"take"},"value":{"kind":"Variable","name":{"kind":"Name","value":"take"}}},{"kind":"Argument","name":{"kind":"Name","value":"orderBy"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/src/frontend/src/pages/admin/tabs/content-create-tab/content-create-tab.graphql b/src/frontend/src/pages/admin/tabs/content-create-tab/content-create-tab.graphql index 96159967..a03e7056 100644 --- a/src/frontend/src/pages/admin/tabs/content-create-tab/content-create-tab.graphql +++ b/src/frontend/src/pages/admin/tabs/content-create-tab/content-create-tab.graphql @@ -9,7 +9,7 @@ query ContentCreateTabData { } } -mutation ContentCreateTab($input: ContentCreateInput!) { +mutation ContentCreateTab($input: CreateContentInput!) { contentCreate(input: $input) { ok } diff --git a/src/frontend/src/pages/admin/tabs/content-create-tab/content-create-tab.tsx b/src/frontend/src/pages/admin/tabs/content-create-tab/content-create-tab.tsx index 7fa0a36b..6a8baae0 100644 --- a/src/frontend/src/pages/admin/tabs/content-create-tab/content-create-tab.tsx +++ b/src/frontend/src/pages/admin/tabs/content-create-tab/content-create-tab.tsx @@ -5,7 +5,7 @@ import { toaster } from "~/core/chakra-components/ui/toaster"; import { Form, z } from "~/core/form"; import { useSafeQuery } from "~/core/graphql"; import { - ContentCreateInput, + CreateContentInput, ContentCreateTabDataDocument, ContentCreateTabDocument, ContentCreateTabMutation, @@ -40,7 +40,7 @@ export const ContentCreateTab = () => { return (
- + defaultValues={{ contentRewards: data.items.map(({ id }) => ({ averageQuantity: 0, diff --git a/src/frontend/src/pages/content-wage-list/components/content-group-wage-list-table.tsx b/src/frontend/src/pages/content-wage-list/components/content-group-wage-list-table.tsx index 0c8d67cc..dfa42158 100644 --- a/src/frontend/src/pages/content-wage-list/components/content-group-wage-list-table.tsx +++ b/src/frontend/src/pages/content-wage-list/components/content-group-wage-list-table.tsx @@ -24,9 +24,9 @@ export const ContentGroupWageListTable = () => { const { isAuthenticated } = useAuth(); const { contentCategoryId, - includeIsBound, - includeIsSeeMore, + includeBound, includeItemIds, + includeSeeMore, keyword, shouldMergeGate, } = useContentWageListPage(); @@ -43,9 +43,9 @@ export const ContentGroupWageListTable = () => { variables: { filter: { contentCategoryId, - includeIsBound, - includeIsSeeMore, + includeBound, includeItemIds, + includeSeeMore, keyword, status: ContentStatus.ACTIVE, }, diff --git a/src/frontend/src/pages/content-wage-list/components/content-wage-list-filters.tsx b/src/frontend/src/pages/content-wage-list/components/content-wage-list-filters.tsx index 95511142..f461bc16 100644 --- a/src/frontend/src/pages/content-wage-list/components/content-wage-list-filters.tsx +++ b/src/frontend/src/pages/content-wage-list/components/content-wage-list-filters.tsx @@ -77,7 +77,7 @@ export const ContentWageListFilters = () => { }; const ContentSeeMoreFilter = () => { - const { includeIsSeeMore, setIncludeIsSeeMore } = useContentWageListPage(); + const { includeSeeMore, setIncludeSeeMore } = useContentWageListPage(); return ( { { label: "미포함", value: "false" }, { label: "포함", value: "true" }, ]} - onValueChange={(e) => setIncludeIsSeeMore(e.value === "true")} - value={includeIsSeeMore ? "true" : "false"} + onValueChange={(e) => setIncludeSeeMore(e.value === "true")} + value={includeSeeMore ? "true" : "false"} /> ); }; const ContentIsBoundFilter = () => { - const { includeIsBound, setIncludeIsBound } = useContentWageListPage(); + const { includeBound, setIncludeBound } = useContentWageListPage(); return ( { { label: "미포함", value: "false" }, { label: "포함", value: "true" }, ]} - onValueChange={(e) => setIncludeIsBound(e.value === "true")} - value={includeIsBound ? "true" : "false"} + onValueChange={(e) => setIncludeBound(e.value === "true")} + value={includeBound ? "true" : "false"} /> ); }; diff --git a/src/frontend/src/pages/content-wage-list/components/content-wage-list-table.tsx b/src/frontend/src/pages/content-wage-list/components/content-wage-list-table.tsx index 3a11aeba..7404c12f 100644 --- a/src/frontend/src/pages/content-wage-list/components/content-wage-list-table.tsx +++ b/src/frontend/src/pages/content-wage-list/components/content-wage-list-table.tsx @@ -24,9 +24,9 @@ export const ContentWageListTable = () => { const { isAuthenticated } = useAuth(); const { contentCategoryId, - includeIsBound, - includeIsSeeMore, + includeBound, includeItemIds, + includeSeeMore, keyword, shouldMergeGate, } = useContentWageListPage(); @@ -43,9 +43,9 @@ export const ContentWageListTable = () => { variables: { filter: { contentCategoryId, - includeIsBound, - includeIsSeeMore, + includeBound, includeItemIds, + includeSeeMore, keyword, status: ContentStatus.ACTIVE, }, diff --git a/src/frontend/src/pages/content-wage-list/components/custom-content-wage-calculate-dialog.graphql b/src/frontend/src/pages/content-wage-list/components/custom-content-wage-calculate-dialog.graphql index 3cb2095a..52fc49fb 100644 --- a/src/frontend/src/pages/content-wage-list/components/custom-content-wage-calculate-dialog.graphql +++ b/src/frontend/src/pages/content-wage-list/components/custom-content-wage-calculate-dialog.graphql @@ -6,7 +6,7 @@ query CustomContentWageCalculateDialogQuery { } mutation CustomContentWageCalculateDialogMutation( - $input: CustomContentWageCalculateInput! + $input: CalculateCustomContentWageInput! ) { customContentWageCalculate(input: $input) { krwAmountPerHour diff --git a/src/frontend/src/pages/content-wage-list/components/custom-content-wage-calculate-dialog.tsx b/src/frontend/src/pages/content-wage-list/components/custom-content-wage-calculate-dialog.tsx index 1284b2e1..42a0b23d 100644 --- a/src/frontend/src/pages/content-wage-list/components/custom-content-wage-calculate-dialog.tsx +++ b/src/frontend/src/pages/content-wage-list/components/custom-content-wage-calculate-dialog.tsx @@ -10,8 +10,8 @@ import { CustomContentWageCalculateDialogMutationDocument, CustomContentWageCalculateDialogMutationMutation, CustomContentWageCalculateDialogQueryDocument, - CustomContentWageCalculateInput, - CustomContentWageCalculateResult, + CalculateCustomContentWageInput, + CalculateCustomContentWageResult, CustomContentWageCalculateDialogQueryQuery, } from "~/core/graphql/generated"; @@ -27,11 +27,11 @@ const schema = z.object({ }); export const CustomContentWageCalculateDialog = (dialogProps: DialogProps) => { - const [result, setResult] = useState(); + const [result, setResult] = useState(); return ( diff --git a/src/frontend/src/pages/content-wage-list/components/gold-exchange-rate-setting-dialog.graphql b/src/frontend/src/pages/content-wage-list/components/gold-exchange-rate-setting-dialog.graphql index e7fe5875..3ddc1e5c 100644 --- a/src/frontend/src/pages/content-wage-list/components/gold-exchange-rate-setting-dialog.graphql +++ b/src/frontend/src/pages/content-wage-list/components/gold-exchange-rate-setting-dialog.graphql @@ -6,7 +6,7 @@ query GoldExchangeRateSettingDialog { } } -mutation GoldExchangeRateEdit($input: GoldExchangeRateEditInput!) { +mutation GoldExchangeRateEdit($input: EditGoldExchangeRateInput!) { goldExchangeRateEdit(input: $input) { ok } diff --git a/src/frontend/src/pages/content-wage-list/components/gold-exchange-rate-setting-dialog.tsx b/src/frontend/src/pages/content-wage-list/components/gold-exchange-rate-setting-dialog.tsx index 7d5c1ae9..4fa60ccd 100644 --- a/src/frontend/src/pages/content-wage-list/components/gold-exchange-rate-setting-dialog.tsx +++ b/src/frontend/src/pages/content-wage-list/components/gold-exchange-rate-setting-dialog.tsx @@ -3,7 +3,7 @@ import { Dialog, DialogProps } from "~/core/dialog"; import { Form, z } from "~/core/form"; import { GoldExchangeRateEditDocument, - GoldExchangeRateEditInput, + EditGoldExchangeRateInput, GoldExchangeRateEditMutation, GoldExchangeRateSettingDialogDocument, GoldExchangeRateSettingDialogQuery, @@ -21,7 +21,7 @@ export const GoldExchangeRateSettingDialog = ({ } & DialogProps) => { return ( diff --git a/src/frontend/src/pages/content-wage-list/content-wage-list-page-context.tsx b/src/frontend/src/pages/content-wage-list/content-wage-list-page-context.tsx index e5f81c88..b4f93d3a 100644 --- a/src/frontend/src/pages/content-wage-list/content-wage-list-page-context.tsx +++ b/src/frontend/src/pages/content-wage-list/content-wage-list-page-context.tsx @@ -13,19 +13,19 @@ import { ItemsFilterDocument, ItemsFilterQuery } from "~/core/graphql/generated" type ContentWageListPageContextType = { contentCategoryId: number | null; - includeIsBound?: boolean; - includeIsSeeMore?: boolean; - + includeBound?: boolean; includeItemIds: number[]; + + includeSeeMore?: boolean; items: ItemsFilterQuery["items"]; keyword: string; setContentCategoryId: (id: number | null) => void; - setIncludeIsBound: (value: boolean) => void; - setIncludeIsSeeMore: (value: boolean) => void; - + setIncludeBound: (value: boolean) => void; setIncludeItemIds: Dispatch>; + + setIncludeSeeMore: (value: boolean) => void; setKeyword: (value: string) => void; setShouldMergeGate: (value: boolean) => void; @@ -42,8 +42,8 @@ export const ContentWageListPageProvider = ({ children }: PropsWithChildren) => } = useSafeQuery(ItemsFilterDocument); const [contentCategoryId, setContentCategoryId] = useState(null); const [keyword, setKeyword] = useState(""); - const [includeIsSeeMore, setIncludeIsSeeMore] = useState(false); - const [includeIsBound, setIncludeIsBound] = useState(false); + const [includeSeeMore, setIncludeSeeMore] = useState(false); + const [includeBound, setIncludeBound] = useState(false); const [includeItemIds, setIncludeItemIds] = useState(items.map((item) => item.id)); const [shouldMergeGate, setShouldMergeGate] = useState(true); @@ -51,15 +51,15 @@ export const ContentWageListPageProvider = ({ children }: PropsWithChildren) => { return ( { - const [includeIsSeeMore, setIncludeIsSeeMore] = useState(false); - const [includeIsBound, setIncludeIsBound] = useState(false); + const [includeSeeMore, setIncludeSeeMore] = useState(false); + const [includeBound, setIncludeBound] = useState(false); const [includeItemIds, setIncludeItemIds] = useState(items.map(({ id }) => id)); return ( @@ -203,14 +203,14 @@ const ContentWageSection = ({ @@ -223,9 +223,9 @@ const ContentWageSection = ({ }>
@@ -234,23 +234,23 @@ const ContentWageSection = ({ const ContentWageSectionDataGrid = ({ contentId, - includeIsBound, - includeIsSeeMore, + includeBound, includeItemIds, + includeSeeMore, }: { contentId: number; - includeIsBound: boolean; - includeIsSeeMore: boolean; + includeBound: boolean; includeItemIds: number[]; + includeSeeMore: boolean; }) => { const { isAuthenticated } = useAuth(); const { data, refetch } = useSafeQuery(ContentDetailsDialogWageSectionDocument, { variables: { contentId, filter: { - includeIsBound, - includeIsSeeMore, + includeBound, includeItemIds, + includeSeeMore, }, }, }); @@ -294,11 +294,11 @@ const ContentWageSectionDataGrid = ({ }; const ContentSeeMoreFilter = ({ - includeIsSeeMore, - setIncludeIsSeeMore, + includeSeeMore, + setIncludeSeeMore, }: { - includeIsSeeMore: boolean; - setIncludeIsSeeMore: (value: boolean) => void; + includeSeeMore: boolean; + setIncludeSeeMore: (value: boolean) => void; }) => { return ( setIncludeIsSeeMore(e.value === "true")} - value={includeIsSeeMore ? "true" : "false"} + onValueChange={(e) => setIncludeSeeMore(e.value === "true")} + value={includeSeeMore ? "true" : "false"} /> ); }; const ContentIsBoundFilter = ({ - includeIsBound, - setIncludeIsBound, + includeBound, + setIncludeBound, }: { - includeIsBound: boolean; - setIncludeIsBound: (value: boolean) => void; + includeBound: boolean; + setIncludeBound: (value: boolean) => void; }) => { return ( setIncludeIsBound(e.value === "true")} - value={includeIsBound ? "true" : "false"} + onValueChange={(e) => setIncludeBound(e.value === "true")} + value={includeBound ? "true" : "false"} /> ); }; diff --git a/src/frontend/src/shared/content/content-duration-edit-dialog.graphql b/src/frontend/src/shared/content/content-duration-edit-dialog.graphql index 15ae8f26..9f2d39fc 100644 --- a/src/frontend/src/shared/content/content-duration-edit-dialog.graphql +++ b/src/frontend/src/shared/content/content-duration-edit-dialog.graphql @@ -5,7 +5,7 @@ query ContentDurationEditDialog($id: Int!) { } } -mutation ContentDurationEdit($input: ContentDurationEditInput!) { +mutation ContentDurationEdit($input: EditContentDurationInput!) { contentDurationEdit(input: $input) { ok } diff --git a/src/frontend/src/shared/content/content-duration-edit-dialog.tsx b/src/frontend/src/shared/content/content-duration-edit-dialog.tsx index 283a3720..7970b1b7 100644 --- a/src/frontend/src/shared/content/content-duration-edit-dialog.tsx +++ b/src/frontend/src/shared/content/content-duration-edit-dialog.tsx @@ -6,7 +6,7 @@ import { Form, z } from "~/core/form"; import { ContentDurationEditDialogDocument, ContentDurationEditDocument, - ContentDurationEditInput, + EditContentDurationInput, ContentDurationEditMutation, ContentDurationEditDialogQuery, ContentDurationEditDialogQueryVariables, @@ -30,7 +30,7 @@ export const ContentDurationEditDialog = ({ }: ContentDurationEditDialogProps & DialogProps) => { return ( { - const [includeIsSeeMore, setIncludeIsSeeMore] = useState(false); - const [includeIsBound, setIncludeIsBound] = useState(false); + const [includeSeeMore, setIncludeSeeMore] = useState(false); + const [includeBound, setIncludeBound] = useState(false); const [includeItemIds, setIncludeItemIds] = useState(items.map(({ id }) => id)); return ( @@ -229,14 +229,14 @@ const ContentWageSection = ({ @@ -249,9 +249,9 @@ const ContentWageSection = ({ }> @@ -260,23 +260,23 @@ const ContentWageSection = ({ const ContentWageSectionDataGrid = ({ contentId, - includeIsBound, - includeIsSeeMore, + includeBound, includeItemIds, + includeSeeMore, }: { contentId: number; - includeIsBound: boolean; - includeIsSeeMore: boolean; + includeBound: boolean; includeItemIds: number[]; + includeSeeMore: boolean; }) => { const { isAuthenticated } = useAuth(); const { data, refetch } = useSafeQuery(ContentDetailsDialogWageSectionDocument, { variables: { contentId, filter: { - includeIsBound, - includeIsSeeMore, + includeBound, includeItemIds, + includeSeeMore, }, }, }); @@ -320,11 +320,11 @@ const ContentWageSectionDataGrid = ({ }; const ContentSeeMoreFilter = ({ - includeIsSeeMore, - setIncludeIsSeeMore, + includeSeeMore, + setIncludeSeeMore, }: { - includeIsSeeMore: boolean; - setIncludeIsSeeMore: (value: boolean) => void; + includeSeeMore: boolean; + setIncludeSeeMore: (value: boolean) => void; }) => { return ( setIncludeIsSeeMore(e.value === "true")} - value={includeIsSeeMore ? "true" : "false"} + onValueChange={(e) => setIncludeSeeMore(e.value === "true")} + value={includeSeeMore ? "true" : "false"} /> ); }; const ContentIsBoundFilter = ({ - includeIsBound, - setIncludeIsBound, + includeBound, + setIncludeBound, }: { - includeIsBound: boolean; - setIncludeIsBound: (value: boolean) => void; + includeBound: boolean; + setIncludeBound: (value: boolean) => void; }) => { return ( setIncludeIsBound(e.value === "true")} - value={includeIsBound ? "true" : "false"} + onValueChange={(e) => setIncludeBound(e.value === "true")} + value={includeBound ? "true" : "false"} /> ); }; diff --git a/src/frontend/src/shared/content/content-group-duration-edit-dialog.graphql b/src/frontend/src/shared/content/content-group-duration-edit-dialog.graphql index 7748640a..dc787e86 100644 --- a/src/frontend/src/shared/content/content-group-duration-edit-dialog.graphql +++ b/src/frontend/src/shared/content/content-group-duration-edit-dialog.graphql @@ -9,7 +9,7 @@ query ContentGroupDurationEditDialog($ids: [Int!]!) { } } -mutation ContentDurationsEdit($input: ContentDurationsEditInput!) { +mutation ContentDurationsEdit($input: EditContentDurationsInput!) { contentDurationsEdit(input: $input) { ok } diff --git a/src/frontend/src/shared/content/content-group-duration-edit-dialog.tsx b/src/frontend/src/shared/content/content-group-duration-edit-dialog.tsx index cd928eb7..40b78eb9 100644 --- a/src/frontend/src/shared/content/content-group-duration-edit-dialog.tsx +++ b/src/frontend/src/shared/content/content-group-duration-edit-dialog.tsx @@ -5,7 +5,7 @@ import { Dialog, DialogProps } from "~/core/dialog"; import { Form, z } from "~/core/form"; import { ContentDurationsEditDocument, - ContentDurationsEditInput, + EditContentDurationsInput, ContentDurationsEditMutation, ContentGroupDurationEditDialogDocument, ContentGroupDurationEditDialogQuery, @@ -34,7 +34,7 @@ export const ContentGroupDurationEditDialog = ({ }: ContentGroupDurationEditDialogProps & DialogProps) => { return ( { return ( { return ( { return (