From 3f0668936600c24fdc6018ac2a16a62d34975f8d Mon Sep 17 00:00:00 2001 From: yubbbbbbi Date: Fri, 28 Aug 2026 22:06:23 +0900 Subject: [PATCH 1/8] feat(be): integrate Mandeuldang problem schema --- .../migration.sql | 116 +++++++++ apps/backend/prisma/schema.prisma | 235 +++++++----------- 2 files changed, 206 insertions(+), 145 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260828120159_integrate_mandeuldang_problem/migration.sql diff --git a/apps/backend/prisma/migrations/20260828120159_integrate_mandeuldang_problem/migration.sql b/apps/backend/prisma/migrations/20260828120159_integrate_mandeuldang_problem/migration.sql new file mode 100644 index 0000000000..c252165cb1 --- /dev/null +++ b/apps/backend/prisma/migrations/20260828120159_integrate_mandeuldang_problem/migration.sql @@ -0,0 +1,116 @@ +/* + Warnings: + + - The values [Viewer] on the enum `CollaboratorRole` will be removed. If these variants are still used in the database, this will fail. + - The values [Active] on the enum `CollaboratorStatus` will be removed. If these variants are still used in the database, this will fail. + - You are about to drop the `mandeuldang_approval_request` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `mandeuldang_problem` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `mandeuldang_sample` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- CreateEnum +CREATE TYPE "public"."ProblemCreationMode" AS ENUM ('Legacy', 'Mandeuldang'); + +-- CreateEnum +CREATE TYPE "public"."ProblemStatus" AS ENUM ('Draft', 'Ready', 'Published'); + +-- CreateEnum +CREATE TYPE "public"."ProblemType" AS ENUM ('General', 'SpecialJudge'); + +-- AlterEnum +BEGIN; +CREATE TYPE "public"."CollaboratorRole_new" AS ENUM ('Owner', 'Editor', 'Reviewer'); +ALTER TABLE "public"."mandeuldang_collaborator" ALTER COLUMN "role" TYPE "public"."CollaboratorRole_new" USING ("role"::text::"public"."CollaboratorRole_new"); +ALTER TYPE "public"."CollaboratorRole" RENAME TO "CollaboratorRole_old"; +ALTER TYPE "public"."CollaboratorRole_new" RENAME TO "CollaboratorRole"; +DROP TYPE "public"."CollaboratorRole_old"; +COMMIT; + +-- AlterEnum +BEGIN; +CREATE TYPE "public"."CollaboratorStatus_new" AS ENUM ('Pending', 'Approved'); +ALTER TABLE "public"."mandeuldang_collaborator" ALTER COLUMN "status" DROP DEFAULT; +ALTER TABLE "public"."mandeuldang_collaborator" ALTER COLUMN "status" TYPE "public"."CollaboratorStatus_new" USING ("status"::text::"public"."CollaboratorStatus_new"); +ALTER TYPE "public"."CollaboratorStatus" RENAME TO "CollaboratorStatus_old"; +ALTER TYPE "public"."CollaboratorStatus_new" RENAME TO "CollaboratorStatus"; +DROP TYPE "public"."CollaboratorStatus_old"; +ALTER TABLE "public"."mandeuldang_collaborator" ALTER COLUMN "status" SET DEFAULT 'Pending'; +COMMIT; + +-- DropForeignKey +ALTER TABLE "public"."mandeuldang_approval_request" DROP CONSTRAINT "mandeuldang_approval_request_problem_id_fkey"; + +-- DropForeignKey +ALTER TABLE "public"."mandeuldang_approval_request" DROP CONSTRAINT "mandeuldang_approval_request_requester_id_fkey"; + +-- DropForeignKey +ALTER TABLE "public"."mandeuldang_approval_request" DROP CONSTRAINT "mandeuldang_approval_request_reviewer_id_fkey"; + +-- DropForeignKey +ALTER TABLE "public"."mandeuldang_collaborator" DROP CONSTRAINT "mandeuldang_collaborator_problem_id_fkey"; + +-- DropForeignKey +ALTER TABLE "public"."mandeuldang_problem" DROP CONSTRAINT "mandeuldang_problem_created_by_id_fkey"; + +-- DropForeignKey +ALTER TABLE "public"."mandeuldang_run_request" DROP CONSTRAINT "mandeuldang_run_request_problem_id_fkey"; + +-- DropForeignKey +ALTER TABLE "public"."mandeuldang_sample" DROP CONSTRAINT "mandeuldang_sample_problem_id_fkey"; + +-- DropForeignKey +ALTER TABLE "public"."mandeuldang_solution" DROP CONSTRAINT "mandeuldang_solution_problem_id_fkey"; + +-- DropForeignKey +ALTER TABLE "public"."mandeuldang_test_file" DROP CONSTRAINT "mandeuldang_test_file_problem_id_fkey"; + +-- DropForeignKey +ALTER TABLE "public"."mandeuldang_tool" DROP CONSTRAINT "mandeuldang_tool_problem_id_fkey"; + +-- AlterTable +ALTER TABLE "public"."problem" ADD COLUMN "creation_mode" "public"."ProblemCreationMode" NOT NULL DEFAULT 'Legacy', +ADD COLUMN "last_run_pass" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "problem_type" "public"."ProblemType" NOT NULL DEFAULT 'General', +ADD COLUMN "status" "public"."ProblemStatus" NOT NULL DEFAULT 'Published', +ALTER COLUMN "description" DROP NOT NULL, +ALTER COLUMN "input_description" DROP NOT NULL, +ALTER COLUMN "output_description" DROP NOT NULL, +ALTER COLUMN "hint" DROP NOT NULL, +ALTER COLUMN "time_limit" DROP NOT NULL, +ALTER COLUMN "memory_limit" DROP NOT NULL, +ALTER COLUMN "source" DROP NOT NULL, +ALTER COLUMN "languages" SET DEFAULT ARRAY[]::"public"."Language"[], +ALTER COLUMN "difficulty" DROP NOT NULL; + +-- DropTable +DROP TABLE "public"."mandeuldang_approval_request"; + +-- DropTable +DROP TABLE "public"."mandeuldang_problem"; + +-- DropTable +DROP TABLE "public"."mandeuldang_sample"; + +-- DropEnum +DROP TYPE "public"."MandeuldangApprovalStatus"; + +-- DropEnum +DROP TYPE "public"."MandeuldangProblemStatus"; + +-- CreateIndex +CREATE INDEX "problem_created_by_id_creation_mode_status_idx" ON "public"."problem"("created_by_id", "creation_mode", "status"); + +-- AddForeignKey +ALTER TABLE "public"."mandeuldang_test_file" ADD CONSTRAINT "mandeuldang_test_file_problem_id_fkey" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."mandeuldang_solution" ADD CONSTRAINT "mandeuldang_solution_problem_id_fkey" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."mandeuldang_tool" ADD CONSTRAINT "mandeuldang_tool_problem_id_fkey" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."mandeuldang_collaborator" ADD CONSTRAINT "mandeuldang_collaborator_problem_id_fkey" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."mandeuldang_run_request" ADD CONSTRAINT "mandeuldang_run_request_problem_id_fkey" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index a884c1a69c..7aba5fe4ef 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -63,33 +63,30 @@ model User { createdCourseQnAs CourseQnA[] @relation("CreatedByUserCourseQnA") createdCourseQnAComments CourseQnAComment[] @relation("CreatedByUserCourseQnAComment") - userProfile UserProfile? - userGroup UserGroup[] - notice Notice[] - problem Problem[] - assignment Assignment[] - assignmentRecord AssignmentRecord[] - contest Contest[] - userContest UserContest[] - contestRecord ContestRecord[] - workbook Workbook[] - submission Submission[] - useroauth UserOAuth[] - file File[] - testSubmission TestSubmission[] - UpdateHistory UpdateHistory[] - createdQnAs ContestQnA[] @relation("CreatedByUser") - createdQnAComments ContestQnAComment[] @relation("CreatedByUser") - NotificationRecord NotificationRecord[] - PushSubscription PushSubscription[] - CourseNotice CourseNotice[] - CourseNoticeComment CourseNoticeComment[] - CheckRequest CheckRequest[] - mandeuldangProblems MandeuldangProblem[] @relation("MandeuldangProblemOwner") - mandeuldangCollaborations MandeuldangCollaborator[] @relation("MandeuldangCollaborator") - mandeuldangApprovalRequests MandeuldangApprovalRequest[] @relation("MandeuldangApprovalRequester") - mandeuldangReviewedApprovals MandeuldangApprovalRequest[] @relation("MandeuldangApprovalReviewer") - mandeuldangRunRequests MandeuldangRunRequest[] @relation("MandeuldangRunRequester") + userProfile UserProfile? + userGroup UserGroup[] + notice Notice[] + problem Problem[] + assignment Assignment[] + assignmentRecord AssignmentRecord[] + contest Contest[] + userContest UserContest[] + contestRecord ContestRecord[] + workbook Workbook[] + submission Submission[] + useroauth UserOAuth[] + file File[] + testSubmission TestSubmission[] + UpdateHistory UpdateHistory[] + createdQnAs ContestQnA[] @relation("CreatedByUser") + createdQnAComments ContestQnAComment[] @relation("CreatedByUser") + NotificationRecord NotificationRecord[] + PushSubscription PushSubscription[] + CourseNotice CourseNotice[] + CourseNoticeComment CourseNoticeComment[] + CheckRequest CheckRequest[] + mandeuldangCollaborations MandeuldangCollaborator[] @relation("MandeuldangCollaborator") + mandeuldangRunRequests MandeuldangRunRequest[] @relation("MandeuldangRunRequester") @@map("user") } @@ -249,11 +246,17 @@ model Problem { createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) createdById Int? @map("created_by_id") + // 문제 생성 방식 및 제작 상태 + creationMode ProblemCreationMode @default(Legacy) @map("creation_mode") + status ProblemStatus @default(Published) + lastRunPass Boolean @default(false) @map("last_run_pass") + problemType ProblemType @default(General) @map("problem_type") + title String - description String - inputDescription String @map("input_description") - outputDescription String @map("output_description") - hint String + description String? + inputDescription String? @map("input_description") + outputDescription String? @map("output_description") + hint String? // 문제 정보의 영어 버전 제공은 선택사항임 engTitle String? @map("eng_title") @@ -272,25 +275,27 @@ model Problem { /// }[] /// } - template Json[] @default([]) - languages Language[] - solution Json[] @default([]) - timeLimit Int @map("time_limit") // unit: MilliSeconds - memoryLimit Int @map("memory_limit") // unit: MegaBytes - difficulty Level - source String - submissionCount Int @default(0) @map("submission_count") - acceptedCount Int @default(0) @map("accepted_count") - acceptedRate Float @default(0) @map("accepted_rate") - visibleLockTime DateTime @map("visible_lock_time") + template Json[] @default([]) + languages Language[] @default([]) + solution Json[] @default([]) + timeLimit Int? @map("time_limit") // unit: MilliSeconds + memoryLimit Int? @map("memory_limit") // unit: MegaBytes + difficulty Level? + source String? + + submissionCount Int @default(0) @map("submission_count") + acceptedCount Int @default(0) @map("accepted_count") + acceptedRate Float @default(0) @map("accepted_rate") + visibleLockTime DateTime @map("visible_lock_time") // 문제가 속한 대회들이 모두 끝나는 시각으로 이후 문제의 공개 여부를 변경가능, 속한 대회가 없을 경우 MIN_DATE // MIN_DATE의 경우 모든 사용자에게 공개, 이외에는 비공개 - createTime DateTime @default(now()) @map("create_time") - updateTime DateTime @updatedAt @map("update_time") - updateContentTime DateTime? @map("update_content_time") - isSampleUploadedByZip Boolean @default(false) @map("is_sample_uploaded_by_zip") - isHiddenUploadedByZip Boolean @default(false) @map("is_hidden_uploaded_by_zip") + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + updateContentTime DateTime? @map("update_content_time") + isSampleUploadedByZip Boolean @default(false) @map("is_sample_uploaded_by_zip") + isHiddenUploadedByZip Boolean @default(false) @map("is_hidden_uploaded_by_zip") + // 기존 Problem 관계 sharedGroups Group[] problemTestcase ProblemTestcase[] problemTag ProblemTag[] @@ -301,13 +306,18 @@ model Problem { announcement Announcement[] ContestQnA ContestQnA[] courseQnA CourseQnA[] @relation("ProblemToCourseQnA") + updateHistory UpdateHistory[] + testSubmission TestSubmission[] + CheckRequest CheckRequest[] - updateHistory UpdateHistory[] - - testSubmission TestSubmission[] - - CheckRequest CheckRequest[] + // 만들당 관계 + mandeuldangTestFiles MandeuldangTestFile[] + mandeuldangSolution MandeuldangSolution? + mandeuldangTools MandeuldangTool[] + mandeuldangCollaborators MandeuldangCollaborator[] + mandeuldangRunRequests MandeuldangRunRequest[] + @@index([createdById, creationMode, status]) @@map("problem") } @@ -1034,14 +1044,22 @@ model CourseQnAComment { @@map("course_qna_comment") } -enum MandeuldangProblemStatus { +enum ProblemCreationMode { + Legacy + Mandeuldang +} + +enum ProblemStatus { Draft Ready - PendingApproval - Rejected Published } +enum ProblemType { + General + SpecialJudge +} + enum TestFileType { IN OUT @@ -1050,18 +1068,12 @@ enum TestFileType { enum CollaboratorRole { Owner Editor - Viewer + Reviewer } enum CollaboratorStatus { - Pending - Active -} - -enum MandeuldangApprovalStatus { Pending Approved - Rejected } enum ToolType { @@ -1077,56 +1089,10 @@ enum MandeuldangRunStatus { Failed } -model MandeuldangProblem { - id Int @id @default(autoincrement()) - createdBy User? @relation("MandeuldangProblemOwner", fields: [createdById], references: [id]) - createdById Int? @map("created_by_id") - - // Statement - title String - description String - inputDescription String @map("input_description") - outputDescription String @map("output_description") - timeLimit Int @map("time_limit") // unit: MilliSeconds - memoryLimit Int @map("memory_limit") // unit: MegaBytes - languages Language[] - difficulty Level - - // Status - status MandeuldangProblemStatus @default(Draft) - lastRunPass Boolean @default(false) @map("last_run_pass") - - createTime DateTime @default(now()) @map("create_time") - updateTime DateTime @updatedAt @map("update_time") - mandeuldangSamples MandeuldangSample[] - mandeuldangTestFiles MandeuldangTestFile[] - mandeuldangSolution MandeuldangSolution? - mandeuldangTools MandeuldangTool[] - mandeuldangCollaborators MandeuldangCollaborator[] - mandeuldangApprovalRequests MandeuldangApprovalRequest[] - mandeuldangRunRequests MandeuldangRunRequest[] - - @@map("mandeuldang_problem") -} - -model MandeuldangSample { - id Int @id @default(autoincrement()) - problem MandeuldangProblem @relation(fields: [problemId], references: [id], onDelete: Cascade) - problemId Int @map("problem_id") - - inputText String @map("input_text") - outputText String @map("output_text") - order Int - - createTime DateTime @default(now()) @map("create_time") - - @@map("mandeuldang_sample") -} - model MandeuldangTestFile { - id Int @id @default(autoincrement()) - problem MandeuldangProblem @relation(fields: [problemId], references: [id], onDelete: Cascade) - problemId Int @map("problem_id") + id Int @id @default(autoincrement()) + problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) + problemId Int @map("problem_id") fileName String @map("file_name") // 원본 파일명 (1.in) baseName String @map("base_name") // 확장자 제외 @@ -1141,9 +1107,9 @@ model MandeuldangTestFile { } model MandeuldangSolution { - id Int @id @default(autoincrement()) - problem MandeuldangProblem @relation(fields: [problemId], references: [id], onDelete: Cascade) - problemId Int @unique @map("problem_id") + id Int @id @default(autoincrement()) + problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) + problemId Int @unique @map("problem_id") fileName String @map("file_name") // 원본 파일명 (solution.cpp) fileContent String @map("file_content") //DB 저장 @@ -1156,9 +1122,9 @@ model MandeuldangSolution { } model MandeuldangTool { - id Int @id @default(autoincrement()) - problem MandeuldangProblem @relation(fields: [problemId], references: [id], onDelete: Cascade) - problemId Int @map("problem_id") + id Int @id @default(autoincrement()) + problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) + problemId Int @map("problem_id") toolType ToolType @map("tool_type") // 'generator' | 'validator' | 'checker' fileName String @map("file_name") @@ -1172,15 +1138,15 @@ model MandeuldangTool { } model MandeuldangCollaborator { - id Int @id @default(autoincrement()) - problem MandeuldangProblem @relation(fields: [problemId], references: [id], onDelete: Cascade) - problemId Int @map("problem_id") + id Int @id @default(autoincrement()) + problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) + problemId Int @map("problem_id") user User @relation("MandeuldangCollaborator", fields: [userId], references: [id]) userId Int @map("user_id") - role CollaboratorRole // Owner | Editor | Viewer - status CollaboratorStatus @default(Pending) // Pending | Active + role CollaboratorRole // Owner | Editor | Reviewer + status CollaboratorStatus @default(Pending) // Pending | Approved createTime DateTime @default(now()) @map("create_time") @@ -1188,32 +1154,11 @@ model MandeuldangCollaborator { @@map("mandeuldang_collaborator") } -model MandeuldangApprovalRequest { - id Int @id @default(autoincrement()) - problem MandeuldangProblem @relation(fields: [problemId], references: [id], onDelete: Cascade) - problemId Int @map("problem_id") - - requester User @relation("MandeuldangApprovalRequester", fields: [requesterId], references: [id]) - requesterId Int @map("requester_id") - - status MandeuldangApprovalStatus @default(Pending) // Pending | Approved | Rejected - message String? // 제출 시 메시지 - submittedAt DateTime @default(now()) @map("submitted_at") - - reviewer User? @relation("MandeuldangApprovalReviewer", fields: [reviewerId], references: [id]) - reviewerId Int? @map("reviewer_id") - reviewedAt DateTime? @map("reviewed_at") - - rejectionReason String? @map("rejection_reason") - - @@map("mandeuldang_approval_request") -} - model MandeuldangRunRequest { id Int @id @default(autoincrement()) - problem MandeuldangProblem @relation(fields: [problemId], references: [id], onDelete: Cascade) - problemId Int @map("problem_id") + problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) + problemId Int @map("problem_id") requester User @relation("MandeuldangRunRequester", fields: [requesterId], references: [id]) requesterId Int @map("requester_id") From 192c26b109d057ef161c63250d3434e7619eb380 Mon Sep 17 00:00:00 2001 From: yubbbbbbi Date: Fri, 28 Aug 2026 22:46:16 +0900 Subject: [PATCH 2/8] fix(be): adapt references to unified problem model --- .../src/assignment/assignment.service.spec.ts | 12 +++++++++- .../src/contest/test/contest.service.spec.ts | 14 ++++++++++- .../mandeuldang/mandeuldang-sub.service.ts | 4 ++-- .../src/mandeuldang/mandeuldang.resolver.ts | 8 ++----- .../apps/admin/src/problem/mock/mock.ts | 23 ++++++++++++++++++- .../client/src/problem/mock/problem.mock.ts | 17 +++++++++++++- .../src/submission/mock/problem.mock.ts | 13 ++++++++++- 7 files changed, 78 insertions(+), 13 deletions(-) diff --git a/apps/backend/apps/admin/src/assignment/assignment.service.spec.ts b/apps/backend/apps/admin/src/assignment/assignment.service.spec.ts index 40ec5b98fc..483eb7ddda 100644 --- a/apps/backend/apps/admin/src/assignment/assignment.service.spec.ts +++ b/apps/backend/apps/admin/src/assignment/assignment.service.spec.ts @@ -10,7 +10,13 @@ import { Problem } from '@generated' import { faker } from '@faker-js/faker' -import { Prisma, ResultStatus } from '@prisma/client' +import { + Prisma, + ProblemCreationMode, + ProblemStatus, + ProblemType, + ResultStatus +} from '@prisma/client' import { expect } from 'chai' import { stub } from 'sinon' import { @@ -129,6 +135,10 @@ const group: Group = { const problem: Problem = { id: problemId, createdById: 2, + creationMode: ProblemCreationMode.Legacy, + status: ProblemStatus.Published, + lastRunPass: false, + problemType: ProblemType.General, title: 'test problem', description: 'thisistestproblem', inputDescription: 'inputdescription', diff --git a/apps/backend/apps/admin/src/contest/test/contest.service.spec.ts b/apps/backend/apps/admin/src/contest/test/contest.service.spec.ts index c4fe91ed00..c941ba8a85 100644 --- a/apps/backend/apps/admin/src/contest/test/contest.service.spec.ts +++ b/apps/backend/apps/admin/src/contest/test/contest.service.spec.ts @@ -2,7 +2,15 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test, type TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' -import { ContestRole, Prisma, ResultStatus, Role } from '@prisma/client' +import { + ContestRole, + Prisma, + ProblemCreationMode, + ProblemStatus, + ProblemType, + ResultStatus, + Role +} from '@prisma/client' import { expect } from 'chai' import { stub, type SinonStub } from 'sinon' import { MAX_DATE } from '@libs/constants' @@ -103,6 +111,10 @@ const contestWithParticipants: ContestWithParticipants = { const problem: Problem = { id: problemId, createdById: 2, + creationMode: ProblemCreationMode.Legacy, + status: ProblemStatus.Published, + lastRunPass: false, + problemType: ProblemType.General, title: 'test problem', description: 'thisistestproblem', inputDescription: 'inputdescription', diff --git a/apps/backend/apps/admin/src/mandeuldang/mandeuldang-sub.service.ts b/apps/backend/apps/admin/src/mandeuldang/mandeuldang-sub.service.ts index 14236bf9f7..aec2b4c7d1 100644 --- a/apps/backend/apps/admin/src/mandeuldang/mandeuldang-sub.service.ts +++ b/apps/backend/apps/admin/src/mandeuldang/mandeuldang-sub.service.ts @@ -121,7 +121,7 @@ export class MandeuldangSubscriptionService implements OnModuleInit { completedAt: now } }), - this.prisma.mandeuldangProblem.update({ + this.prisma.problem.update({ where: { id: request.problemId }, data: { lastRunPass: isSuccess } }) @@ -169,7 +169,7 @@ export class MandeuldangSubscriptionService implements OnModuleInit { completedAt: now } }), - this.prisma.mandeuldangProblem.update({ + this.prisma.problem.update({ where: { id: request.problemId }, data: { lastRunPass: isSuccess } }) diff --git a/apps/backend/apps/admin/src/mandeuldang/mandeuldang.resolver.ts b/apps/backend/apps/admin/src/mandeuldang/mandeuldang.resolver.ts index 2fe209b205..f9ba810334 100644 --- a/apps/backend/apps/admin/src/mandeuldang/mandeuldang.resolver.ts +++ b/apps/backend/apps/admin/src/mandeuldang/mandeuldang.resolver.ts @@ -3,14 +3,10 @@ import { ToolType } from '@prisma/client' import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs' import type { FileUpload } from 'graphql-upload/processRequest.mjs' import { UseDisableAdminGuard, type AuthenticatedRequest } from '@libs/auth' -import { - MandeuldangProblem, - MandeuldangRunRequest, - MandeuldangTool -} from '@admin/@generated' +import { MandeuldangRunRequest, MandeuldangTool } from '@admin/@generated' import { MandeuldangService } from './mandeuldang.service' -@Resolver(() => MandeuldangProblem) +@Resolver() @UseDisableAdminGuard() export class MandeuldangResolver { constructor(private readonly mandeuldangService: MandeuldangService) {} diff --git a/apps/backend/apps/admin/src/problem/mock/mock.ts b/apps/backend/apps/admin/src/problem/mock/mock.ts index d311e6a50d..54c9dbb19c 100644 --- a/apps/backend/apps/admin/src/problem/mock/mock.ts +++ b/apps/backend/apps/admin/src/problem/mock/mock.ts @@ -11,7 +11,12 @@ import type { } from '@generated' import { Language, Level } from '@generated' import { faker } from '@faker-js/faker' -import { Role } from '@prisma/client' +import { + ProblemCreationMode, + ProblemStatus, + ProblemType, + Role +} from '@prisma/client' import { createReadStream } from 'fs' import { MAX_DATE, MIN_DATE } from '@libs/constants' import type { FileUploadDto } from '../dto/file-upload.dto' @@ -70,6 +75,10 @@ export const problems: Problem[] = [ { id: 1, createdById: user[0].id!, + creationMode: ProblemCreationMode.Legacy, + status: ProblemStatus.Published, + lastRunPass: false, + problemType: ProblemType.General, title: 'group problem0', description: 'description1', inputDescription: 'inputDescription1', @@ -101,6 +110,10 @@ export const problems: Problem[] = [ { id: 2, createdById: user[0].id!, + creationMode: ProblemCreationMode.Legacy, + status: ProblemStatus.Published, + lastRunPass: false, + problemType: ProblemType.General, title: 'group problem1', description: 'description2', inputDescription: 'inputDescription2', @@ -236,6 +249,10 @@ export const importedProblems: Problem[] = [ { id: 32, createdById: user[1].id!, + creationMode: ProblemCreationMode.Legacy, + status: ProblemStatus.Published, + lastRunPass: false, + problemType: ProblemType.General, title: '정수 더하기', description: '

두 정수 A와 B를 입력받은 다음, A+B를 출력하는 프로그램을 작성하시오. 첫째 줄에 A와 B가 주어진다. (0 < A, B < 10) 첫째 줄에 A+B를 출력한다.

', @@ -275,6 +292,10 @@ export const importedProblems: Problem[] = [ { id: 33, createdById: user[1].id!, + creationMode: ProblemCreationMode.Legacy, + status: ProblemStatus.Published, + lastRunPass: false, + problemType: ProblemType.General, title: '정수 빼기', description: '

두 정수 A와 B를 입력받은 다음, A-B를 출력하는 프로그램을 작성하시오. 첫째 줄에 A와 B가 주어진다. (0 < A, B < 10) 첫째 줄에 A-B를 출력한다.

', diff --git a/apps/backend/apps/client/src/problem/mock/problem.mock.ts b/apps/backend/apps/client/src/problem/mock/problem.mock.ts index 1d96a66140..ef32f7558f 100644 --- a/apps/backend/apps/client/src/problem/mock/problem.mock.ts +++ b/apps/backend/apps/client/src/problem/mock/problem.mock.ts @@ -1,5 +1,12 @@ import { faker } from '@faker-js/faker' -import { Language, Level, Role } from '@prisma/client' +import { + Language, + Level, + ProblemCreationMode, + ProblemStatus, + ProblemType, + Role +} from '@prisma/client' import type { Contest, ContestProblem, @@ -14,6 +21,10 @@ export const problems: Problem[] = [ { id: 1, createdById: 1, + creationMode: ProblemCreationMode.Legacy, + status: ProblemStatus.Published, + lastRunPass: false, + problemType: ProblemType.General, title: 'public problem', description: '', inputDescription: '', @@ -45,6 +56,10 @@ export const problems: Problem[] = [ { id: 2, createdById: 1, + creationMode: ProblemCreationMode.Legacy, + status: ProblemStatus.Published, + lastRunPass: false, + problemType: ProblemType.General, title: 'problem', description: '', inputDescription: '', diff --git a/apps/backend/apps/client/src/submission/mock/problem.mock.ts b/apps/backend/apps/client/src/submission/mock/problem.mock.ts index 1d9d724cb6..39aff0b737 100644 --- a/apps/backend/apps/client/src/submission/mock/problem.mock.ts +++ b/apps/backend/apps/client/src/submission/mock/problem.mock.ts @@ -1,11 +1,22 @@ import { faker } from '@faker-js/faker' -import { type Problem, Language, Level } from '@prisma/client' +import { + type Problem, + Language, + Level, + ProblemCreationMode, + ProblemStatus, + ProblemType +} from '@prisma/client' import { MIN_DATE } from '@libs/constants' export const problems: Problem[] = [ { id: 1, createdById: 1, + creationMode: ProblemCreationMode.Legacy, + status: ProblemStatus.Published, + lastRunPass: false, + problemType: ProblemType.General, title: 'public problem', description: '', inputDescription: '', From 2f19c9f895877f497e30fec288d29ddb1d581472 Mon Sep 17 00:00:00 2001 From: yubbbbbbi Date: Fri, 28 Aug 2026 23:54:12 +0900 Subject: [PATCH 3/8] chore(be): scaffold Mandeuldang problem module --- .../src/mandeuldang/problem/problem.module.ts | 8 +++++++ .../resolvers/problem.resolver.spec.ts | 21 +++++++++++++++++++ .../problem/resolvers/problem.resolver.ts | 4 ++++ .../problem/services/problem.service.spec.ts | 19 +++++++++++++++++ .../problem/services/problem.service.ts | 4 ++++ 5 files changed, 56 insertions(+) create mode 100644 apps/backend/apps/admin/src/mandeuldang/problem/problem.module.ts create mode 100644 apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts create mode 100644 apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts create mode 100644 apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts create mode 100644 apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/problem.module.ts b/apps/backend/apps/admin/src/mandeuldang/problem/problem.module.ts new file mode 100644 index 0000000000..a86882fed5 --- /dev/null +++ b/apps/backend/apps/admin/src/mandeuldang/problem/problem.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' +import { MandeuldangProblemResolver } from './resolvers/problem.resolver' +import { MandeuldangProblemService } from './services/problem.service' + +@Module({ + providers: [MandeuldangProblemService, MandeuldangProblemResolver] +}) +export class MandeuldangProblemModule {} diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts new file mode 100644 index 0000000000..5b971564f7 --- /dev/null +++ b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts @@ -0,0 +1,21 @@ +import { Test, type TestingModule } from '@nestjs/testing' +import { expect } from 'chai' +import { MandeuldangProblemResolver } from './problem.resolver' + +describe('MandeuldangProblemResolver', () => { + let resolver: MandeuldangProblemResolver + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [MandeuldangProblemResolver] + }).compile() + + resolver = module.get( + MandeuldangProblemResolver + ) + }) + + it('should be defined', () => { + expect(resolver).to.be.ok + }) +}) diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts new file mode 100644 index 0000000000..5fe123b7a2 --- /dev/null +++ b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts @@ -0,0 +1,4 @@ +import { Resolver } from '@nestjs/graphql' + +@Resolver() +export class MandeuldangProblemResolver {} diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts new file mode 100644 index 0000000000..540f51b3a8 --- /dev/null +++ b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts @@ -0,0 +1,19 @@ +import { Test, type TestingModule } from '@nestjs/testing' +import { expect } from 'chai' +import { MandeuldangProblemService } from './problem.service' + +describe('MandeuldangProblemService', () => { + let service: MandeuldangProblemService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [MandeuldangProblemService] + }).compile() + + service = module.get(MandeuldangProblemService) + }) + + it('should be defined', () => { + expect(service).to.be.ok + }) +}) diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts new file mode 100644 index 0000000000..5da8e2c8d2 --- /dev/null +++ b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common' + +@Injectable() +export class MandeuldangProblemService {} From a00f44029a1f2398ba054cd09ec8d3b1757b0343 Mon Sep 17 00:00:00 2001 From: yubbbbbbi Date: Fri, 28 Aug 2026 23:57:59 +0900 Subject: [PATCH 4/8] chore(be): scaffold Mandeuldang problem module --- apps/backend/apps/admin/src/mandeuldang/mandeuldang.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/apps/admin/src/mandeuldang/mandeuldang.module.ts b/apps/backend/apps/admin/src/mandeuldang/mandeuldang.module.ts index ef08518e6c..e6fa51d03f 100644 --- a/apps/backend/apps/admin/src/mandeuldang/mandeuldang.module.ts +++ b/apps/backend/apps/admin/src/mandeuldang/mandeuldang.module.ts @@ -6,9 +6,10 @@ import { MandeuldangPublicationService } from './mandeuldang-pub.service' import { MandeuldangSubscriptionService } from './mandeuldang-sub.service' import { MandeuldangResolver } from './mandeuldang.resolver' import { MandeuldangService } from './mandeuldang.service' +import { MandeuldangProblemModule } from './problem/problem.module' @Module({ - imports: [RolesModule, AMQPModule], + imports: [RolesModule, AMQPModule, MandeuldangProblemModule], providers: [ MandeuldangResolver, MandeuldangService, From b547acfc37cf8dbcc32f02eb6578889b1108245c Mon Sep 17 00:00:00 2001 From: han25-ya <78807337+han25-ya@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:03:53 +0900 Subject: [PATCH 5/8] feat(be): implement Mandeuldang problem read queries and legacy compatibility (#3732) Stacked on #3715 (t2853-implement-mandeuldang-problem). Task B: Mandeuldang problem read queries + legacy Problem compatibility. --- .../problem/model/problem.output.ts | 49 +++ .../resolvers/problem.resolver.spec.ts | 73 ++++- .../problem/resolvers/problem.resolver.ts | 56 +++- .../problem/services/problem.service.spec.ts | 284 +++++++++++++++++- .../problem/services/problem.service.ts | 170 ++++++++++- .../problem/services/problem.service.spec.ts | 16 +- .../src/problem/services/problem.service.ts | 13 +- .../src/submission/submission.service.ts | 13 +- .../src/problem/dto/problem.response.dto.ts | 19 +- .../src/problem/dto/problems.response.dto.ts | 2 +- .../client/src/problem/problem.service.ts | 11 +- .../src/submission/submission-pub.service.ts | 20 +- .../src/submission/submission.service.ts | 20 +- .../test/submission-pub.service.spec.ts | 17 +- .../test/submission.service.spec.ts | 93 +++++- 15 files changed, 817 insertions(+), 39 deletions(-) create mode 100644 apps/backend/apps/admin/src/mandeuldang/problem/model/problem.output.ts diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/model/problem.output.ts b/apps/backend/apps/admin/src/mandeuldang/problem/model/problem.output.ts new file mode 100644 index 0000000000..00d03aeb27 --- /dev/null +++ b/apps/backend/apps/admin/src/mandeuldang/problem/model/problem.output.ts @@ -0,0 +1,49 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' +import { CollaboratorRole, Problem } from '@admin/@generated' + +/** + * 만들당 문제 상세/목록 조회 결과. + * + * DB는 MandeuldangProblem이라는 별도 모델 없이 기존 Problem을 그대로 쓰기로 결정됐으므로 + * (백엔드 회의 08.20 결론), 자동 생성된 Problem GraphQL 타입을 그대로 확장한다 — + * ProblemWithIsVisible(../../problem/model/problem.output.ts)이 이미 같은 패턴을 쓰고 있다. + * + * 목록 전용 Output 타입은 따로 만들지 않았다. 목록 조회에서는 아래 관계·계산 필드를 + * 채우지 않고 undefined로 두면 되므로(전부 nullable), 상세 조회와 타입을 공유해도 + * 계약이 깨지지 않는다. + */ +@ObjectType() +export class MandeuldangProblemOutput extends Problem { + @Field(() => CollaboratorRole, { + nullable: true, + description: + '요청한 사용자가 이 문제에 대해 가진 협업 역할. Owner/Editor/Reviewer가 아니면 null.' + }) + myRole?: `${CollaboratorRole}` | null + + // mandeuldangCollaborators/mandeuldangSolution/mandeuldangTools는 기존 생성된 + // Problem 타입에 이미 관계 필드로 선언돼 있어(부모 필드) 여기서 다시 선언하지 않는다 — + // 서비스가 Prisma include로 채워 넣은 값이 그대로 상속된 필드에 실린다. + + @Field(() => Int, { + nullable: true, + description: '등록된 테스트 파일(.in/.out 쌍 기준이 아니라 개별 파일 개수)' + }) + testFileCount?: number + + @Field(() => Boolean, { + nullable: true, + description: + '지금 상태로 발행 가능한지 여부. 상세 조회에서만 계산해 채운다.' + }) + canPublish?: boolean + + @Field(() => [String], { + nullable: true, + description: + 'canPublish가 false일 때 무엇이 부족한지 나타내는 코드 목록 ' + + '(STATEMENT/SOLUTION/TEST_FILES). 실제 발행 가능 여부의 최종 판단과 발행 자체는 ' + + 'Update/발행 담당 쪽에서 이뤄지므로, 이 값은 참고용 미리보기다.' + }) + missingForPublish?: string[] +} diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts index 5b971564f7..c3b5f54a78 100644 --- a/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts +++ b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts @@ -1,13 +1,34 @@ import { Test, type TestingModule } from '@nestjs/testing' +import { ProblemStatus, Role } from '@prisma/client' import { expect } from 'chai' +import { stub } from 'sinon' +import type { AuthenticatedRequest } from '@libs/auth' +import { MandeuldangProblemService } from '../services/problem.service' import { MandeuldangProblemResolver } from './problem.resolver' +const problemService = { + getMyProblems: stub(), + getInProgressProblems: stub(), + getProblem: stub() +} + +const req = { + user: { id: 1, role: Role.User } +} as unknown as AuthenticatedRequest + describe('MandeuldangProblemResolver', () => { let resolver: MandeuldangProblemResolver beforeEach(async () => { + problemService.getMyProblems.reset() + problemService.getInProgressProblems.reset() + problemService.getProblem.reset() + const module: TestingModule = await Test.createTestingModule({ - providers: [MandeuldangProblemResolver] + providers: [ + MandeuldangProblemResolver, + { provide: MandeuldangProblemService, useValue: problemService } + ] }).compile() resolver = module.get( @@ -18,4 +39,54 @@ describe('MandeuldangProblemResolver', () => { it('should be defined', () => { expect(resolver).to.be.ok }) + + it('getMyMandeuldangProblems delegates to the service with the requester id', async () => { + problemService.getMyProblems.resolves([]) + + await resolver.getMyMandeuldangProblems(req, null, 10, undefined) + + expect( + problemService.getMyProblems.calledOnceWith(1, null, 10, undefined) + ).to.equal(true) + }) + + it('getMyMandeuldangProblems forwards an explicit status filter', async () => { + problemService.getMyProblems.resolves([]) + + await resolver.getMyMandeuldangProblems(req, null, 10, ProblemStatus.Draft) + + expect( + problemService.getMyProblems.calledOnceWith( + 1, + null, + 10, + ProblemStatus.Draft + ) + ).to.equal(true) + }) + + it('getInProgressMandeuldangProblems delegates to the service with the requester id', async () => { + problemService.getInProgressProblems.resolves([]) + + await resolver.getInProgressMandeuldangProblems(req, null, 10, undefined) + + expect( + problemService.getInProgressProblems.calledOnceWith( + 1, + null, + 10, + undefined + ) + ).to.equal(true) + }) + + it('getMandeuldangProblem delegates to the service with id, requester id, and role', async () => { + problemService.getProblem.resolves({}) + + await resolver.getMandeuldangProblem(req, 42) + + expect(problemService.getProblem.calledOnceWith(42, 1, Role.User)).to.equal( + true + ) + }) }) diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts index 5fe123b7a2..ae5f1df5b2 100644 --- a/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts +++ b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts @@ -1,4 +1,54 @@ -import { Resolver } from '@nestjs/graphql' +import { Args, Context, Int, Query, Resolver } from '@nestjs/graphql' +import { ProblemStatus } from '@prisma/client' +import { AuthenticatedRequest, UseDisableAdminGuard } from '@libs/auth' +import { CursorValidationPipe, RequiredIntPipe } from '@libs/pipe' +import { MandeuldangProblemOutput } from '../model/problem.output' +import { MandeuldangProblemService } from '../services/problem.service' -@Resolver() -export class MandeuldangProblemResolver {} +@Resolver(() => MandeuldangProblemOutput) +@UseDisableAdminGuard() +export class MandeuldangProblemResolver { + constructor(private readonly problemService: MandeuldangProblemService) {} + + @Query(() => [MandeuldangProblemOutput]) + async getMyMandeuldangProblems( + @Context('req') req: AuthenticatedRequest, + @Args('cursor', { nullable: true, type: () => Int }, CursorValidationPipe) + cursor: number | null, + @Args('take', { defaultValue: 10, type: () => Int }) take: number, + @Args('status', { nullable: true, type: () => ProblemStatus }) + status?: ProblemStatus + ) { + return await this.problemService.getMyProblems( + req.user.id, + cursor, + take, + status + ) + } + + @Query(() => [MandeuldangProblemOutput]) + async getInProgressMandeuldangProblems( + @Context('req') req: AuthenticatedRequest, + @Args('cursor', { nullable: true, type: () => Int }, CursorValidationPipe) + cursor: number | null, + @Args('take', { defaultValue: 10, type: () => Int }) take: number, + @Args('status', { nullable: true, type: () => ProblemStatus }) + status?: ProblemStatus + ) { + return await this.problemService.getInProgressProblems( + req.user.id, + cursor, + take, + status + ) + } + + @Query(() => MandeuldangProblemOutput) + async getMandeuldangProblem( + @Context('req') req: AuthenticatedRequest, + @Args('id', { type: () => Int }, new RequiredIntPipe('id')) id: number + ) { + return await this.problemService.getProblem(id, req.user.id, req.user.role) + } +} diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts index 540f51b3a8..499e8b9270 100644 --- a/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts +++ b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts @@ -1,13 +1,56 @@ import { Test, type TestingModule } from '@nestjs/testing' +import { + CollaboratorRole, + CollaboratorStatus, + ProblemCreationMode, + ProblemStatus, + Role +} from '@prisma/client' import { expect } from 'chai' +import { stub } from 'sinon' +import { PrismaService } from '@libs/prisma' import { MandeuldangProblemService } from './problem.service' +const ownerId = 1 +const collaboratorId = 2 +const strangerId = 3 + +const baseProblem = { + id: 10, + createdById: ownerId, + creationMode: ProblemCreationMode.Mandeuldang, + status: ProblemStatus.Draft, + description: null, + mandeuldangCollaborators: [] as Array<{ + userId: number + role: CollaboratorRole + status: CollaboratorStatus + }>, + mandeuldangSolution: null, + mandeuldangTools: [], + mandeuldangTestFiles: [] as unknown[] +} + +const db = { + problem: { + findMany: stub(), + findUnique: stub() + }, + getPaginator: PrismaService.prototype.getPaginator +} + describe('MandeuldangProblemService', () => { let service: MandeuldangProblemService beforeEach(async () => { + db.problem.findMany.reset() + db.problem.findUnique.reset() + const module: TestingModule = await Test.createTestingModule({ - providers: [MandeuldangProblemService] + providers: [ + MandeuldangProblemService, + { provide: PrismaService, useValue: db } + ] }).compile() service = module.get(MandeuldangProblemService) @@ -16,4 +59,243 @@ describe('MandeuldangProblemService', () => { it('should be defined', () => { expect(service).to.be.ok }) + + describe('getMyProblems', () => { + it('filters by creationMode=Mandeuldang and createdById, regardless of status by default', async () => { + db.problem.findMany.resolves([baseProblem]) + + await service.getMyProblems(ownerId, null, 10) + + const call = db.problem.findMany.firstCall.args[0] + expect(call.where).to.deep.equal({ + creationMode: ProblemCreationMode.Mandeuldang, + createdById: ownerId + }) + }) + + it('narrows to a specific status when one is given', async () => { + db.problem.findMany.resolves([]) + + await service.getMyProblems(ownerId, null, 10, ProblemStatus.Ready) + + const call = db.problem.findMany.firstCall.args[0] + expect(call.where.status).to.equal(ProblemStatus.Ready) + }) + + it('reports myRole=Owner on every item in the list', async () => { + db.problem.findMany.resolves([baseProblem]) + + const result = await service.getMyProblems(ownerId, null, 10) + + expect(result[0].myRole).to.equal(CollaboratorRole.Owner) + }) + }) + + describe('getInProgressProblems', () => { + it('excludes Published problems and includes owner OR approved-collaborator scope by default', async () => { + db.problem.findMany.resolves([]) + + await service.getInProgressProblems(ownerId, null, 10) + + const call = db.problem.findMany.firstCall.args[0] + expect(call.where.status).to.deep.equal({ not: ProblemStatus.Published }) + expect(call.where.OR).to.deep.equal([ + { createdById: ownerId }, + { + mandeuldangCollaborators: { + some: { userId: ownerId, status: CollaboratorStatus.Approved } + } + } + ]) + }) + + it('narrows to a specific status when one is given, instead of "not Published"', async () => { + db.problem.findMany.resolves([]) + + await service.getInProgressProblems( + ownerId, + null, + 10, + ProblemStatus.Draft + ) + + const call = db.problem.findMany.firstCall.args[0] + expect(call.where.status).to.equal(ProblemStatus.Draft) + }) + + it('reports myRole for a collaborator entry, distinct from owned entries', async () => { + const ownedItem = { ...baseProblem, id: 1, createdById: collaboratorId } + const collaboratingItem = { + ...baseProblem, + id: 2, + createdById: ownerId, + mandeuldangCollaborators: [ + { + userId: collaboratorId, + role: CollaboratorRole.Reviewer, + status: CollaboratorStatus.Approved + } + ] + } + db.problem.findMany.resolves([ownedItem, collaboratingItem]) + + const result = await service.getInProgressProblems( + collaboratorId, + null, + 10 + ) + + expect(result[0].myRole).to.equal(CollaboratorRole.Owner) + expect(result[1].myRole).to.equal(CollaboratorRole.Reviewer) + }) + }) + + describe('getProblem', () => { + it('throws EntityNotExistException when the problem does not exist', async () => { + db.problem.findUnique.resolves(null) + + try { + await service.getProblem(999, ownerId, Role.User) + expect.fail('should have thrown') + } catch (err) { + expect((err as Error).message).to.include('MandeuldangProblem') + } + }) + + it('throws EntityNotExistException for a Legacy (non-Mandeuldang) problem', async () => { + db.problem.findUnique.resolves({ + ...baseProblem, + creationMode: ProblemCreationMode.Legacy + }) + + try { + await service.getProblem(baseProblem.id, ownerId, Role.User) + expect.fail('should have thrown') + } catch (err) { + expect((err as Error).message).to.include('MandeuldangProblem') + } + }) + + it('allows the owner to view their own Draft problem and reports myRole=Owner', async () => { + db.problem.findUnique.resolves(baseProblem) + + const result = await service.getProblem( + baseProblem.id, + ownerId, + Role.User + ) + + expect(result.myRole).to.equal(CollaboratorRole.Owner) + expect(result.testFileCount).to.equal(0) + expect(result.canPublish).to.equal(false) + expect(result.missingForPublish).to.include.members([ + 'STATEMENT', + 'SOLUTION', + 'TEST_FILES' + ]) + }) + + it('allows an approved collaborator to view a Draft problem they do not own', async () => { + db.problem.findUnique.resolves({ + ...baseProblem, + mandeuldangCollaborators: [ + { + userId: collaboratorId, + role: CollaboratorRole.Editor, + status: CollaboratorStatus.Approved + } + ] + }) + + const result = await service.getProblem( + baseProblem.id, + collaboratorId, + Role.User + ) + + expect(result.myRole).to.equal(CollaboratorRole.Editor) + }) + + it('rejects a pending (not yet approved) collaborator from viewing a Draft problem', async () => { + db.problem.findUnique.resolves({ + ...baseProblem, + mandeuldangCollaborators: [ + { + userId: collaboratorId, + role: CollaboratorRole.Editor, + status: CollaboratorStatus.Pending + } + ] + }) + + try { + await service.getProblem(baseProblem.id, collaboratorId, Role.User) + expect.fail('should have thrown') + } catch (err) { + expect((err as Error).message).to.include('Only the owner') + } + }) + + it('rejects an unrelated user from viewing a Draft problem', async () => { + db.problem.findUnique.resolves(baseProblem) + + try { + await service.getProblem(baseProblem.id, strangerId, Role.User) + expect.fail('should have thrown') + } catch (err) { + expect((err as Error).message).to.include('Only the owner') + } + }) + + it('lets an Admin view a Draft problem they neither own nor collaborate on', async () => { + db.problem.findUnique.resolves(baseProblem) + + const result = await service.getProblem( + baseProblem.id, + strangerId, + Role.Admin + ) + + expect(result.myRole).to.equal(null) + }) + + it('lets anyone view a Published problem, even a stranger with no privilege', async () => { + db.problem.findUnique.resolves({ + ...baseProblem, + status: ProblemStatus.Published, + description: 'a real statement', + mandeuldangSolution: { id: 1 }, + mandeuldangTestFiles: [{ id: 1 }, { id: 2 }, { id: 3 }] + }) + + const result = await service.getProblem( + baseProblem.id, + strangerId, + Role.User + ) + + expect(result.testFileCount).to.equal(3) + expect(result.canPublish).to.equal(true) + expect(result.missingForPublish).to.deep.equal([]) + }) + + it('returns the actual test file list, not just a count', async () => { + const testFiles = [ + { id: 1, fileName: '1.in' }, + { id: 2, fileName: '1.out' } + ] + db.problem.findUnique.resolves({ + ...baseProblem, + mandeuldangTestFiles: testFiles + }) + + const result = await service.getProblem( + baseProblem.id, + ownerId, + Role.User + ) + + expect(result.mandeuldangTestFiles).to.deep.equal(testFiles) + }) + }) }) diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts index 5da8e2c8d2..3208337416 100644 --- a/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts +++ b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts @@ -1,4 +1,172 @@ import { Injectable } from '@nestjs/common' +import { + CollaboratorRole, + CollaboratorStatus, + ProblemCreationMode, + ProblemStatus, + Role +} from '@prisma/client' +import { + EntityNotExistException, + ForbiddenAccessException +} from '@libs/exception' +import { PrismaService } from '@libs/prisma' +import type { MandeuldangProblemOutput } from '../model/problem.output' + +/** 목록/상세 조회 모두에서 재사용하는, "요청자의 협업 역할" 계산 로직. */ +const resolveMyRole = ( + problem: { + createdById: number | null + mandeuldangCollaborators: Array<{ + userId: number + role: CollaboratorRole + status: CollaboratorStatus + }> + }, + userId: number +): `${CollaboratorRole}` | null => { + // Owner는 항상 Owner 권한을 갖는다 — MandeuldangCollaborator에 Owner 행이 아직 + // 없더라도(협업자 등록은 다른 작업에서 다룬다) createdById로 대체 판단한다. + if (problem.createdById === userId) { + return CollaboratorRole.Owner + } + const myCollaborator = problem.mandeuldangCollaborators.find( + (collaborator) => collaborator.userId === userId + ) + return myCollaborator?.role ?? null +} @Injectable() -export class MandeuldangProblemService {} +export class MandeuldangProblemService { + constructor(private readonly prisma: PrismaService) {} + + /** + * 내가 만든(Owner인) 만들당 문제 목록. 기본적으로 상태와 무관하게 전부 보여준다. + * "management -> 내가 만든 문제" 화면용. `status`를 넘기면 그 상태로만 좁혀 조회한다. + */ + async getMyProblems( + userId: number, + cursor: number | null, + take: number, + status?: ProblemStatus + ): Promise { + const paginator = this.prisma.getPaginator(cursor) + const problems = await this.prisma.problem.findMany({ + ...paginator, + take, + where: { + creationMode: ProblemCreationMode.Mandeuldang, + createdById: userId, + ...(status && { status }) + }, + include: { + mandeuldangCollaborators: { where: { userId } } + }, + orderBy: { updateTime: 'desc' } + }) + return problems.map((problem) => ({ + ...problem, + myRole: resolveMyRole(problem, userId) + })) + } + + /** + * 제작 중인(기본적으로 아직 발행되지 않은) 만들당 문제 목록. + * Owner이거나 승인된(Approved) Collaborator로 참여 중인 문제를 모두 포함한다 — + * "내가 만든 문제"보다 넓은 범위다. "management -> 제작 중인 문제" 화면용. + * `status`를 넘기면 "Published가 아님" 대신 그 상태로만(Draft만, Ready만) 좁혀 조회한다. + */ + async getInProgressProblems( + userId: number, + cursor: number | null, + take: number, + status?: ProblemStatus + ): Promise { + const paginator = this.prisma.getPaginator(cursor) + const problems = await this.prisma.problem.findMany({ + ...paginator, + take, + where: { + creationMode: ProblemCreationMode.Mandeuldang, + status: status ?? { not: ProblemStatus.Published }, + OR: [ + { createdById: userId }, + { + mandeuldangCollaborators: { + some: { userId, status: CollaboratorStatus.Approved } + } + } + ] + }, + include: { + mandeuldangCollaborators: { where: { userId } } + }, + orderBy: { updateTime: 'desc' } + }) + return problems.map((problem) => ({ + ...problem, + myRole: resolveMyRole(problem, userId) + })) + } + + /** + * 문제 ID로 상세 조회. Statement, 발행 가능 여부, Solution/Tool/TestFile 목록, + * Collaborator 목록, 요청자의 역할을 한 번에 반환한다. + * + * 접근 권한: Published 문제는 (기존 Problem API와 동등하게) 누구나 조회 가능하다. + * 그 외(Draft/Ready)는 Owner·승인된 Collaborator·Admin/SuperAdmin만 조회할 수 있다. + */ + async getProblem( + id: number, + userId: number, + userRole: Role + ): Promise { + const problem = await this.prisma.problem.findUnique({ + where: { id }, + include: { + mandeuldangCollaborators: { include: { user: true } }, + mandeuldangSolution: true, + mandeuldangTools: true, + mandeuldangTestFiles: true + } + }) + + if (!problem || problem.creationMode !== ProblemCreationMode.Mandeuldang) { + throw new EntityNotExistException('MandeuldangProblem') + } + + const myCollaborator = problem.mandeuldangCollaborators.find( + (collaborator) => collaborator.userId === userId + ) + const isOwner = problem.createdById === userId + const isApprovedCollaborator = + myCollaborator?.status === CollaboratorStatus.Approved + const hasPrivilege = userRole === Role.Admin || userRole === Role.SuperAdmin + + const isVisible = + problem.status === ProblemStatus.Published || + isOwner || + isApprovedCollaborator || + hasPrivilege + + if (!isVisible) { + throw new ForbiddenAccessException( + 'Only the owner, an approved collaborator, or an admin can access a problem that is not yet published' + ) + } + + const testFileCount = problem.mandeuldangTestFiles.length + const missingForPublish: string[] = [] + if (!problem.description) missingForPublish.push('STATEMENT') + if (!problem.mandeuldangSolution) missingForPublish.push('SOLUTION') + if (testFileCount === 0) missingForPublish.push('TEST_FILES') + + return { + ...problem, + myRole: resolveMyRole(problem, userId), + testFileCount, + canPublish: missingForPublish.length === 0, + missingForPublish + } + } +} diff --git a/apps/backend/apps/admin/src/problem/services/problem.service.spec.ts b/apps/backend/apps/admin/src/problem/services/problem.service.spec.ts index a709e6d3cc..5d8fd5571f 100644 --- a/apps/backend/apps/admin/src/problem/services/problem.service.spec.ts +++ b/apps/backend/apps/admin/src/problem/services/problem.service.spec.ts @@ -106,20 +106,22 @@ describe('ProblemService', () => { }) describe('createProblem', () => { + // 이 mock의 필드들은 Problem 전체 타입(nullable)에서 왔지만 실제 값은 항상 + // 채워져 있다 — CreateProblemInput은 (레거시 문제 생성 시 그대로) non-null을 요구한다. const input = { title: problems[0].title, - description: problems[0].description, - inputDescription: problems[0].inputDescription, - outputDescription: problems[0].outputDescription, - hint: problems[0].hint, + description: problems[0].description!, + inputDescription: problems[0].inputDescription!, + outputDescription: problems[0].outputDescription!, + hint: problems[0].hint!, isVisible: false, template: problems[0].template, languages: problems[0].languages, solution: problems[0].solution, - timeLimit: problems[0].timeLimit, - memoryLimit: problems[0].memoryLimit, + timeLimit: problems[0].timeLimit!, + memoryLimit: problems[0].memoryLimit!, difficulty: Level.Level1, - source: problems[0].source, + source: problems[0].source!, testcases: [testcaseInput], tagIds: [1] } diff --git a/apps/backend/apps/admin/src/problem/services/problem.service.ts b/apps/backend/apps/admin/src/problem/services/problem.service.ts index dde9c95f43..a727725255 100644 --- a/apps/backend/apps/admin/src/problem/services/problem.service.ts +++ b/apps/backend/apps/admin/src/problem/services/problem.service.ts @@ -7,7 +7,7 @@ import { ProblemWhereInput, UpdateHistory } from '@generated' -import { ContestRole, ProblemField, Role } from '@prisma/client' +import { ContestRole, ProblemField, ProblemStatus, Role } from '@prisma/client' import { Workbook } from 'exceljs' import { Response } from 'express' import { Readable } from 'stream' @@ -297,6 +297,10 @@ export class ProblemService { const whereOptions: ProblemWhereInput = await this.buildProblemWhereOptionsWithMode(userId, mode, contestId) + // 만들당 Draft/Ready 문제는 전용 화면(제작 중인 문제)에서만 다룬다 — 기존 문제 목록/조회에는 + // 노출하지 않는다. 기존 Legacy 문제는 status가 항상 Published(스키마 기본값)라 영향이 없다. + whereOptions.status = { equals: ProblemStatus.Published } + if (input.difficulty) { whereOptions.difficulty = { in: input.difficulty @@ -401,7 +405,9 @@ export class ProblemService { async getProblem(id: number, userRole: Role, userId: number) { const problem = await this.prisma.problem.findFirstOrThrow({ where: { - id + id, + // 만들당 Draft/Ready 문제는 전용 화면에서만 조회한다 (getProblems와 동일한 정책). + status: ProblemStatus.Published }, include: { sharedGroups: true @@ -736,7 +742,8 @@ export class ProblemService { } // Problem description에 이미지가 포함되어 있다면 삭제 - const uuidImageFileNames = this.extractUUIDs(problem.description) + // 만들당 Draft 문제는 description이 아직 없을 수 있다 (nullable) — 그 경우 추출할 이미지가 없다. + const uuidImageFileNames = this.extractUUIDs(problem.description ?? '') if (uuidImageFileNames) { await this.prisma.file.deleteMany({ where: { diff --git a/apps/backend/apps/admin/src/submission/submission.service.ts b/apps/backend/apps/admin/src/submission/submission.service.ts index adf7be180d..73b6090e10 100644 --- a/apps/backend/apps/admin/src/submission/submission.service.ts +++ b/apps/backend/apps/admin/src/submission/submission.service.ts @@ -19,6 +19,7 @@ import type { AuthenticatedUser } from '@libs/auth' import { EntityNotExistException, ForbiddenAccessException, + UnprocessableDataException, UnprocessableFileDataException } from '@libs/exception' import { PrismaService } from '@libs/prisma' @@ -958,7 +959,17 @@ export class SubmissionService { } } - return { assignment, problem } + // 만들당 Draft/Ready 문제는 timeLimit/memoryLimit이 아직 없을 수 있다(nullable). + // 재채점은 이 값을 Iris로 그대로 보내야 하므로, 없으면 여기서 명확히 막는다 — + // 원래는 발행 검증(publish validation)에서 걸러졌어야 할 상태다. + const { timeLimit, memoryLimit } = problem + if (timeLimit == null || memoryLimit == null) { + throw new UnprocessableDataException( + 'Problem is missing timeLimit/memoryLimit and cannot be rejudged' + ) + } + + return { assignment, problem: { ...problem, timeLimit, memoryLimit } } } /** diff --git a/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts b/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts index 889145bc04..4a4001505b 100644 --- a/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts +++ b/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts @@ -11,20 +11,23 @@ import { Exclude, Expose } from 'class-transformer' export class ProblemResponseDto { id: number title: string - description: string - inputDescription: string - outputDescription: string - hint: string + // 만들당 Draft/Ready 문제는 이 필드들이 아직 채워지지 않을 수 있다(nullable). + // 이 DTO는 status=Published 문제만 반환하는 getProblem()에서 쓰이므로, 정상적으로는 + // 항상 값이 채워져 있어야 한다 — 다만 스키마 자체는 nullable이라 타입도 그에 맞춘다. + description: string | null + inputDescription: string | null + outputDescription: string | null + hint: string | null engTitle: string | null engDescription: string | null engInputDescription: string | null engOutputDescription: string | null engHint: string | null languages: Language[] - timeLimit: number - memoryLimit: number - difficulty: Level - source: string + timeLimit: number | null + memoryLimit: number | null + difficulty: Level | null + source: string | null submissionCount: number acceptedCount: number acceptedRate: number diff --git a/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts b/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts index 4af944a966..dc097d594e 100644 --- a/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts +++ b/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts @@ -10,7 +10,7 @@ class Problem { id: number title: string engTitle: string | null - difficulty: Level + difficulty: Level | null submissionCount: number acceptedRate: number tags: Partial[] diff --git a/apps/backend/apps/client/src/problem/problem.service.ts b/apps/backend/apps/client/src/problem/problem.service.ts index 354514ed07..2fda940833 100644 --- a/apps/backend/apps/client/src/problem/problem.service.ts +++ b/apps/backend/apps/client/src/problem/problem.service.ts @@ -1,5 +1,5 @@ import { ForbiddenException, Injectable } from '@nestjs/common' -import { Prisma, ResultStatus } from '@prisma/client' +import { Prisma, ProblemStatus, ResultStatus } from '@prisma/client' import type { Decimal } from '@prisma/client/runtime/library' import { MIN_DATE } from '@libs/constants' import { ForbiddenAccessException } from '@libs/exception' @@ -108,7 +108,10 @@ export class ProblemService { // 아니면 텍스트가 많은 field에서는 full-text search를 사용하고, 텍스트가 적은 field에서는 contains를 사용하는 방법도 고려해보자. contains: search }, - visibleLockTime: MIN_DATE + visibleLockTime: MIN_DATE, + // 만들당 Draft/Ready 문제는 학생에게 아직 공개되면 안 된다. + // 레거시 문제는 status가 항상 Published(스키마 기본값)라 영향 없다. + status: ProblemStatus.Published }, select: { ...problemsSelectOption, @@ -215,7 +218,9 @@ export class ProblemService { const data = await this.prisma.problem.findUniqueOrThrow({ where: { id: problemId, - visibleLockTime: MIN_DATE + visibleLockTime: MIN_DATE, + // 만들당 Draft/Ready 문제는 학생에게 아직 공개되면 안 된다. + status: ProblemStatus.Published }, select: problemSelectOption }) diff --git a/apps/backend/apps/client/src/submission/submission-pub.service.ts b/apps/backend/apps/client/src/submission/submission-pub.service.ts index fb756aad3b..6f8e20a394 100644 --- a/apps/backend/apps/client/src/submission/submission-pub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-pub.service.ts @@ -2,7 +2,10 @@ import { Injectable } from '@nestjs/common' import type { Submission, TestSubmission } from '@prisma/client' import { Span } from 'nestjs-otel' import { JudgeAMQPService } from '@libs/amqp' -import { EntityNotExistException } from '@libs/exception' +import { + EntityNotExistException, + UnprocessableDataException +} from '@libs/exception' import { PrismaService } from '@libs/prisma' import { Snippet } from './class/create-submission.dto' import { JudgeRequest, UserTestcaseJudgeRequest } from './class/judge-request' @@ -69,18 +72,29 @@ export class SubmissionPublicationService { throw new EntityNotExistException('Problem') } + // 만들당 Draft/Ready 문제는 timeLimit/memoryLimit이 아직 없을 수 있다(nullable) — + // 채점 요청을 만들려면 이 값이 반드시 있어야 하므로 여기서 명확히 막는다. + // 원래는 발행 검증(publish validation)에서 걸러졌어야 할 상태다. + const { timeLimit, memoryLimit } = problem + if (timeLimit == null || memoryLimit == null) { + throw new UnprocessableDataException( + 'Problem is missing timeLimit/memoryLimit and cannot be judged' + ) + } + const judgeableProblem = { ...problem, timeLimit, memoryLimit } + const judgeRequest = isUserTest ? new UserTestcaseJudgeRequest( code, submission.language, - problem, + judgeableProblem, userTestcases!, stopOnNotAccepted ) : new JudgeRequest( code, submission.language, - problem, + judgeableProblem, stopOnNotAccepted, judgeOnlyHiddenTestcases, containHiddenTestcases diff --git a/apps/backend/apps/client/src/submission/submission.service.ts b/apps/backend/apps/client/src/submission/submission.service.ts index e47e7a12dd..f6a84ea8b0 100644 --- a/apps/backend/apps/client/src/submission/submission.service.ts +++ b/apps/backend/apps/client/src/submission/submission.service.ts @@ -7,6 +7,7 @@ import { Language, Prisma, Problem, + ProblemStatus, ResultStatus, Role, Submission, @@ -86,7 +87,10 @@ export class SubmissionService { id: problemId, visibleLockTime: { equals: MIN_DATE - } + }, + // 만들당 Draft/Ready 문제는 발행 전이라 제출을 받으면 안 된다. + // 레거시 문제는 status가 항상 Published(스키마 기본값)라 영향 없다. + status: ProblemStatus.Published } }) if (!problem) { @@ -206,6 +210,10 @@ export class SubmissionService { throw new EntityNotExistException('ContestProblem') } const { problem } = contestProblem + // 만들당 Draft/Ready 문제는 발행 전이라 제출을 받으면 안 된다. + if (problem.status !== ProblemStatus.Published) { + throw new EntityNotExistException('Problem') + } const submission = await this.createSubmission({ submissionDto, @@ -332,6 +340,10 @@ export class SubmissionService { throw new EntityNotExistException('AssignmentProblem') } const { problem } = assignmentProblem + // 만들당 Draft/Ready 문제는 발행 전이라 제출을 받으면 안 된다. + if (problem.status !== ProblemStatus.Published) { + throw new EntityNotExistException('Problem') + } await this.prisma.assignmentProblemRecord.upsert({ where: { @@ -415,7 +427,11 @@ export class SubmissionService { throw new EntityNotExistException('WorkbookProblem') } const { problem } = workbookProblem - if (problem.visibleLockTime.getTime() !== MIN_DATE.getTime()) { + // 만들당 Draft/Ready 문제는 발행 전이라 제출을 받으면 안 된다. + if ( + problem.visibleLockTime.getTime() !== MIN_DATE.getTime() || + problem.status !== ProblemStatus.Published + ) { throw new EntityNotExistException('Problem') } diff --git a/apps/backend/apps/client/src/submission/test/submission-pub.service.spec.ts b/apps/backend/apps/client/src/submission/test/submission-pub.service.spec.ts index efba19a0f2..717bfa5caa 100644 --- a/apps/backend/apps/client/src/submission/test/submission-pub.service.spec.ts +++ b/apps/backend/apps/client/src/submission/test/submission-pub.service.spec.ts @@ -27,6 +27,15 @@ const submission: Submission & { submissionResult: SubmissionResult[] } = { score: new Prisma.Decimal(100) } +// problems[0]는 Problem 전체 타입이라 timeLimit/memoryLimit이 number | null이다 — +// 이 mock은 실제로 항상 값이 채워져 있으므로, JudgeRequest 생성자가 요구하는 +// non-null 타입에 맞춰 좁혀둔다. +const judgeableProblem = { + ...problems[0], + timeLimit: problems[0].timeLimit!, + memoryLimit: problems[0].memoryLimit! +} + describe('SubmissionPublicationService', () => { let service: SubmissionPublicationService let amqpService: JudgeAMQPService @@ -76,7 +85,7 @@ describe('SubmissionPublicationService', () => { const judgeRequest = new JudgeRequest( submissions[0].code, submission.language, - problems[0] + judgeableProblem ) await expect( @@ -112,7 +121,7 @@ describe('SubmissionPublicationService', () => { const judgeRequest = new JudgeRequest( submissions[0].code, submission.language, - problems[0] + judgeableProblem ) await expect( @@ -164,7 +173,7 @@ describe('SubmissionPublicationService', () => { const userTestcaseJudgeRequest = new UserTestcaseJudgeRequest( submissions[0].code, submission.language, - problems[0], + judgeableProblem, userTestcases, true ) @@ -211,7 +220,7 @@ describe('SubmissionPublicationService', () => { const judgeRequest = new JudgeRequest( submissions[0].code, submission.language, - problems[0], + judgeableProblem, true, true, true diff --git a/apps/backend/apps/client/src/submission/test/submission.service.spec.ts b/apps/backend/apps/client/src/submission/test/submission.service.spec.ts index 0e8be95993..a785f7bd6e 100644 --- a/apps/backend/apps/client/src/submission/test/submission.service.spec.ts +++ b/apps/backend/apps/client/src/submission/test/submission.service.spec.ts @@ -4,7 +4,7 @@ import { ConfigService } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import { Test } from '@nestjs/testing' import type { Contest, User, Assignment } from '@prisma/client' -import { Language, Role } from '@prisma/client' +import { Language, ProblemStatus, Role } from '@prisma/client' import type { Cache } from 'cache-manager' import { expect } from 'chai' import { plainToInstance } from 'class-transformer' @@ -244,6 +244,24 @@ describe('SubmissionService', () => { ).to.be.rejectedWith(EntityNotExistException) expect(createSpy.called).to.be.false }) + + it('should only look up Published problems (excludes Draft/Ready mandeuldang problems)', async () => { + db.problem.findFirst.resolves(problems[0]) + stub(service, 'createSubmission') + + await service.submitToProblem({ + submissionDto, + userIp: USERIP, + userId: submissions[0].userId, + problemId: problems[0].id + }) + + expect( + db.problem.findFirst.calledWithMatch({ + where: { status: ProblemStatus.Published } + }) + ).to.be.true + }) }) describe('submitToContest', () => { @@ -285,6 +303,33 @@ describe('SubmissionService', () => { ).to.be.rejectedWith(EntityNotExistException) expect(createSpy.called).to.be.false }) + + it('should throw exception if the problem is not yet published', async () => { + const createSpy = stub(service, 'createSubmission') + db.contest.findFirst.resolves(mockContest) + db.userContest.findFirst.resolves(null) + db.contestRecord.findUnique.resolves({ + contest: { + groupId: 1, + startTime: new Date(Date.now() - 10000), + endTime: new Date(Date.now() + 10000) + } + }) + db.contestProblem.findUnique.resolves({ + problem: { ...problems[0], status: ProblemStatus.Draft } + }) + + await expect( + service.submitToContest({ + submissionDto, + userIp: USERIP, + userId: submissions[0].userId, + problemId: problems[0].id, + contestId: CONTEST_ID + }) + ).to.be.rejectedWith(EntityNotExistException) + expect(createSpy.called).to.be.false + }) }) describe('submitToAssignment', () => { @@ -327,6 +372,34 @@ describe('SubmissionService', () => { ).to.be.rejectedWith(EntityNotExistException) expect(createSpy.called).to.be.false }) + + it('should throw exception if the problem is not yet published', async () => { + const createSpy = stub(service, 'createSubmission') + db.assignment.findFirst.resolves(mockAssignment) + db.assignmentRecord.findUnique.resolves({ + id: 1, + assignment: { + groupId: 1, + startTime: new Date(Date.now() - 10000), + endTime: new Date(Date.now() + 10000), + dueTime: new Date(Date.now() + 5000) + } + }) + db.assignmentProblem.findUnique.resolves({ + problem: { ...problems[0], status: ProblemStatus.Draft } + }) + + await expect( + service.submitToAssignment({ + submissionDto, + userIp: USERIP, + userId: submissions[0].userId, + problemId: problems[0].id, + assignmentId: ASSIGNMENT_ID + }) + ).to.be.rejectedWith(EntityNotExistException) + expect(createSpy.called).to.be.false + }) }) describe('submitToWorkbook', () => { @@ -359,6 +432,24 @@ describe('SubmissionService', () => { ).to.be.rejectedWith(EntityNotExistException) expect(createSpy.called).to.be.false }) + + it('should throw exception if the problem is not yet published', async () => { + const createSpy = stub(service, 'createSubmission') + db.workbookProblem.findUnique.resolves({ + problem: { ...problems[0], status: ProblemStatus.Draft } + }) + + await expect( + service.submitToWorkbook({ + submissionDto, + userIp: USERIP, + userId: submissions[0].userId, + problemId: problems[0].id, + workbookId: WORKBOOK_ID + }) + ).to.be.rejectedWith(EntityNotExistException) + expect(createSpy.called).to.be.false + }) }) describe('createSubmission', () => { From 1503f5530302034aeccf47ce3f8a1346bd48e1e2 Mon Sep 17 00:00:00 2001 From: han25-ya Date: Wed, 2 Sep 2026 22:06:46 +0900 Subject: [PATCH 6/8] Revert "feat(be): implement Mandeuldang problem read queries and legacy compatibility (#3732)" This reverts commit b547acfc37cf8dbcc32f02eb6578889b1108245c. --- .../problem/model/problem.output.ts | 49 --- .../resolvers/problem.resolver.spec.ts | 73 +---- .../problem/resolvers/problem.resolver.ts | 56 +--- .../problem/services/problem.service.spec.ts | 284 +----------------- .../problem/services/problem.service.ts | 170 +---------- .../problem/services/problem.service.spec.ts | 16 +- .../src/problem/services/problem.service.ts | 13 +- .../src/submission/submission.service.ts | 13 +- .../src/problem/dto/problem.response.dto.ts | 19 +- .../src/problem/dto/problems.response.dto.ts | 2 +- .../client/src/problem/problem.service.ts | 11 +- .../src/submission/submission-pub.service.ts | 20 +- .../src/submission/submission.service.ts | 20 +- .../test/submission-pub.service.spec.ts | 17 +- .../test/submission.service.spec.ts | 93 +----- 15 files changed, 39 insertions(+), 817 deletions(-) delete mode 100644 apps/backend/apps/admin/src/mandeuldang/problem/model/problem.output.ts diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/model/problem.output.ts b/apps/backend/apps/admin/src/mandeuldang/problem/model/problem.output.ts deleted file mode 100644 index 00d03aeb27..0000000000 --- a/apps/backend/apps/admin/src/mandeuldang/problem/model/problem.output.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Field, Int, ObjectType } from '@nestjs/graphql' -import { CollaboratorRole, Problem } from '@admin/@generated' - -/** - * 만들당 문제 상세/목록 조회 결과. - * - * DB는 MandeuldangProblem이라는 별도 모델 없이 기존 Problem을 그대로 쓰기로 결정됐으므로 - * (백엔드 회의 08.20 결론), 자동 생성된 Problem GraphQL 타입을 그대로 확장한다 — - * ProblemWithIsVisible(../../problem/model/problem.output.ts)이 이미 같은 패턴을 쓰고 있다. - * - * 목록 전용 Output 타입은 따로 만들지 않았다. 목록 조회에서는 아래 관계·계산 필드를 - * 채우지 않고 undefined로 두면 되므로(전부 nullable), 상세 조회와 타입을 공유해도 - * 계약이 깨지지 않는다. - */ -@ObjectType() -export class MandeuldangProblemOutput extends Problem { - @Field(() => CollaboratorRole, { - nullable: true, - description: - '요청한 사용자가 이 문제에 대해 가진 협업 역할. Owner/Editor/Reviewer가 아니면 null.' - }) - myRole?: `${CollaboratorRole}` | null - - // mandeuldangCollaborators/mandeuldangSolution/mandeuldangTools는 기존 생성된 - // Problem 타입에 이미 관계 필드로 선언돼 있어(부모 필드) 여기서 다시 선언하지 않는다 — - // 서비스가 Prisma include로 채워 넣은 값이 그대로 상속된 필드에 실린다. - - @Field(() => Int, { - nullable: true, - description: '등록된 테스트 파일(.in/.out 쌍 기준이 아니라 개별 파일 개수)' - }) - testFileCount?: number - - @Field(() => Boolean, { - nullable: true, - description: - '지금 상태로 발행 가능한지 여부. 상세 조회에서만 계산해 채운다.' - }) - canPublish?: boolean - - @Field(() => [String], { - nullable: true, - description: - 'canPublish가 false일 때 무엇이 부족한지 나타내는 코드 목록 ' + - '(STATEMENT/SOLUTION/TEST_FILES). 실제 발행 가능 여부의 최종 판단과 발행 자체는 ' + - 'Update/발행 담당 쪽에서 이뤄지므로, 이 값은 참고용 미리보기다.' - }) - missingForPublish?: string[] -} diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts index c3b5f54a78..5b971564f7 100644 --- a/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts +++ b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts @@ -1,34 +1,13 @@ import { Test, type TestingModule } from '@nestjs/testing' -import { ProblemStatus, Role } from '@prisma/client' import { expect } from 'chai' -import { stub } from 'sinon' -import type { AuthenticatedRequest } from '@libs/auth' -import { MandeuldangProblemService } from '../services/problem.service' import { MandeuldangProblemResolver } from './problem.resolver' -const problemService = { - getMyProblems: stub(), - getInProgressProblems: stub(), - getProblem: stub() -} - -const req = { - user: { id: 1, role: Role.User } -} as unknown as AuthenticatedRequest - describe('MandeuldangProblemResolver', () => { let resolver: MandeuldangProblemResolver beforeEach(async () => { - problemService.getMyProblems.reset() - problemService.getInProgressProblems.reset() - problemService.getProblem.reset() - const module: TestingModule = await Test.createTestingModule({ - providers: [ - MandeuldangProblemResolver, - { provide: MandeuldangProblemService, useValue: problemService } - ] + providers: [MandeuldangProblemResolver] }).compile() resolver = module.get( @@ -39,54 +18,4 @@ describe('MandeuldangProblemResolver', () => { it('should be defined', () => { expect(resolver).to.be.ok }) - - it('getMyMandeuldangProblems delegates to the service with the requester id', async () => { - problemService.getMyProblems.resolves([]) - - await resolver.getMyMandeuldangProblems(req, null, 10, undefined) - - expect( - problemService.getMyProblems.calledOnceWith(1, null, 10, undefined) - ).to.equal(true) - }) - - it('getMyMandeuldangProblems forwards an explicit status filter', async () => { - problemService.getMyProblems.resolves([]) - - await resolver.getMyMandeuldangProblems(req, null, 10, ProblemStatus.Draft) - - expect( - problemService.getMyProblems.calledOnceWith( - 1, - null, - 10, - ProblemStatus.Draft - ) - ).to.equal(true) - }) - - it('getInProgressMandeuldangProblems delegates to the service with the requester id', async () => { - problemService.getInProgressProblems.resolves([]) - - await resolver.getInProgressMandeuldangProblems(req, null, 10, undefined) - - expect( - problemService.getInProgressProblems.calledOnceWith( - 1, - null, - 10, - undefined - ) - ).to.equal(true) - }) - - it('getMandeuldangProblem delegates to the service with id, requester id, and role', async () => { - problemService.getProblem.resolves({}) - - await resolver.getMandeuldangProblem(req, 42) - - expect(problemService.getProblem.calledOnceWith(42, 1, Role.User)).to.equal( - true - ) - }) }) diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts index ae5f1df5b2..5fe123b7a2 100644 --- a/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts +++ b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts @@ -1,54 +1,4 @@ -import { Args, Context, Int, Query, Resolver } from '@nestjs/graphql' -import { ProblemStatus } from '@prisma/client' -import { AuthenticatedRequest, UseDisableAdminGuard } from '@libs/auth' -import { CursorValidationPipe, RequiredIntPipe } from '@libs/pipe' -import { MandeuldangProblemOutput } from '../model/problem.output' -import { MandeuldangProblemService } from '../services/problem.service' +import { Resolver } from '@nestjs/graphql' -@Resolver(() => MandeuldangProblemOutput) -@UseDisableAdminGuard() -export class MandeuldangProblemResolver { - constructor(private readonly problemService: MandeuldangProblemService) {} - - @Query(() => [MandeuldangProblemOutput]) - async getMyMandeuldangProblems( - @Context('req') req: AuthenticatedRequest, - @Args('cursor', { nullable: true, type: () => Int }, CursorValidationPipe) - cursor: number | null, - @Args('take', { defaultValue: 10, type: () => Int }) take: number, - @Args('status', { nullable: true, type: () => ProblemStatus }) - status?: ProblemStatus - ) { - return await this.problemService.getMyProblems( - req.user.id, - cursor, - take, - status - ) - } - - @Query(() => [MandeuldangProblemOutput]) - async getInProgressMandeuldangProblems( - @Context('req') req: AuthenticatedRequest, - @Args('cursor', { nullable: true, type: () => Int }, CursorValidationPipe) - cursor: number | null, - @Args('take', { defaultValue: 10, type: () => Int }) take: number, - @Args('status', { nullable: true, type: () => ProblemStatus }) - status?: ProblemStatus - ) { - return await this.problemService.getInProgressProblems( - req.user.id, - cursor, - take, - status - ) - } - - @Query(() => MandeuldangProblemOutput) - async getMandeuldangProblem( - @Context('req') req: AuthenticatedRequest, - @Args('id', { type: () => Int }, new RequiredIntPipe('id')) id: number - ) { - return await this.problemService.getProblem(id, req.user.id, req.user.role) - } -} +@Resolver() +export class MandeuldangProblemResolver {} diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts index 499e8b9270..540f51b3a8 100644 --- a/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts +++ b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts @@ -1,56 +1,13 @@ import { Test, type TestingModule } from '@nestjs/testing' -import { - CollaboratorRole, - CollaboratorStatus, - ProblemCreationMode, - ProblemStatus, - Role -} from '@prisma/client' import { expect } from 'chai' -import { stub } from 'sinon' -import { PrismaService } from '@libs/prisma' import { MandeuldangProblemService } from './problem.service' -const ownerId = 1 -const collaboratorId = 2 -const strangerId = 3 - -const baseProblem = { - id: 10, - createdById: ownerId, - creationMode: ProblemCreationMode.Mandeuldang, - status: ProblemStatus.Draft, - description: null, - mandeuldangCollaborators: [] as Array<{ - userId: number - role: CollaboratorRole - status: CollaboratorStatus - }>, - mandeuldangSolution: null, - mandeuldangTools: [], - mandeuldangTestFiles: [] as unknown[] -} - -const db = { - problem: { - findMany: stub(), - findUnique: stub() - }, - getPaginator: PrismaService.prototype.getPaginator -} - describe('MandeuldangProblemService', () => { let service: MandeuldangProblemService beforeEach(async () => { - db.problem.findMany.reset() - db.problem.findUnique.reset() - const module: TestingModule = await Test.createTestingModule({ - providers: [ - MandeuldangProblemService, - { provide: PrismaService, useValue: db } - ] + providers: [MandeuldangProblemService] }).compile() service = module.get(MandeuldangProblemService) @@ -59,243 +16,4 @@ describe('MandeuldangProblemService', () => { it('should be defined', () => { expect(service).to.be.ok }) - - describe('getMyProblems', () => { - it('filters by creationMode=Mandeuldang and createdById, regardless of status by default', async () => { - db.problem.findMany.resolves([baseProblem]) - - await service.getMyProblems(ownerId, null, 10) - - const call = db.problem.findMany.firstCall.args[0] - expect(call.where).to.deep.equal({ - creationMode: ProblemCreationMode.Mandeuldang, - createdById: ownerId - }) - }) - - it('narrows to a specific status when one is given', async () => { - db.problem.findMany.resolves([]) - - await service.getMyProblems(ownerId, null, 10, ProblemStatus.Ready) - - const call = db.problem.findMany.firstCall.args[0] - expect(call.where.status).to.equal(ProblemStatus.Ready) - }) - - it('reports myRole=Owner on every item in the list', async () => { - db.problem.findMany.resolves([baseProblem]) - - const result = await service.getMyProblems(ownerId, null, 10) - - expect(result[0].myRole).to.equal(CollaboratorRole.Owner) - }) - }) - - describe('getInProgressProblems', () => { - it('excludes Published problems and includes owner OR approved-collaborator scope by default', async () => { - db.problem.findMany.resolves([]) - - await service.getInProgressProblems(ownerId, null, 10) - - const call = db.problem.findMany.firstCall.args[0] - expect(call.where.status).to.deep.equal({ not: ProblemStatus.Published }) - expect(call.where.OR).to.deep.equal([ - { createdById: ownerId }, - { - mandeuldangCollaborators: { - some: { userId: ownerId, status: CollaboratorStatus.Approved } - } - } - ]) - }) - - it('narrows to a specific status when one is given, instead of "not Published"', async () => { - db.problem.findMany.resolves([]) - - await service.getInProgressProblems( - ownerId, - null, - 10, - ProblemStatus.Draft - ) - - const call = db.problem.findMany.firstCall.args[0] - expect(call.where.status).to.equal(ProblemStatus.Draft) - }) - - it('reports myRole for a collaborator entry, distinct from owned entries', async () => { - const ownedItem = { ...baseProblem, id: 1, createdById: collaboratorId } - const collaboratingItem = { - ...baseProblem, - id: 2, - createdById: ownerId, - mandeuldangCollaborators: [ - { - userId: collaboratorId, - role: CollaboratorRole.Reviewer, - status: CollaboratorStatus.Approved - } - ] - } - db.problem.findMany.resolves([ownedItem, collaboratingItem]) - - const result = await service.getInProgressProblems( - collaboratorId, - null, - 10 - ) - - expect(result[0].myRole).to.equal(CollaboratorRole.Owner) - expect(result[1].myRole).to.equal(CollaboratorRole.Reviewer) - }) - }) - - describe('getProblem', () => { - it('throws EntityNotExistException when the problem does not exist', async () => { - db.problem.findUnique.resolves(null) - - try { - await service.getProblem(999, ownerId, Role.User) - expect.fail('should have thrown') - } catch (err) { - expect((err as Error).message).to.include('MandeuldangProblem') - } - }) - - it('throws EntityNotExistException for a Legacy (non-Mandeuldang) problem', async () => { - db.problem.findUnique.resolves({ - ...baseProblem, - creationMode: ProblemCreationMode.Legacy - }) - - try { - await service.getProblem(baseProblem.id, ownerId, Role.User) - expect.fail('should have thrown') - } catch (err) { - expect((err as Error).message).to.include('MandeuldangProblem') - } - }) - - it('allows the owner to view their own Draft problem and reports myRole=Owner', async () => { - db.problem.findUnique.resolves(baseProblem) - - const result = await service.getProblem( - baseProblem.id, - ownerId, - Role.User - ) - - expect(result.myRole).to.equal(CollaboratorRole.Owner) - expect(result.testFileCount).to.equal(0) - expect(result.canPublish).to.equal(false) - expect(result.missingForPublish).to.include.members([ - 'STATEMENT', - 'SOLUTION', - 'TEST_FILES' - ]) - }) - - it('allows an approved collaborator to view a Draft problem they do not own', async () => { - db.problem.findUnique.resolves({ - ...baseProblem, - mandeuldangCollaborators: [ - { - userId: collaboratorId, - role: CollaboratorRole.Editor, - status: CollaboratorStatus.Approved - } - ] - }) - - const result = await service.getProblem( - baseProblem.id, - collaboratorId, - Role.User - ) - - expect(result.myRole).to.equal(CollaboratorRole.Editor) - }) - - it('rejects a pending (not yet approved) collaborator from viewing a Draft problem', async () => { - db.problem.findUnique.resolves({ - ...baseProblem, - mandeuldangCollaborators: [ - { - userId: collaboratorId, - role: CollaboratorRole.Editor, - status: CollaboratorStatus.Pending - } - ] - }) - - try { - await service.getProblem(baseProblem.id, collaboratorId, Role.User) - expect.fail('should have thrown') - } catch (err) { - expect((err as Error).message).to.include('Only the owner') - } - }) - - it('rejects an unrelated user from viewing a Draft problem', async () => { - db.problem.findUnique.resolves(baseProblem) - - try { - await service.getProblem(baseProblem.id, strangerId, Role.User) - expect.fail('should have thrown') - } catch (err) { - expect((err as Error).message).to.include('Only the owner') - } - }) - - it('lets an Admin view a Draft problem they neither own nor collaborate on', async () => { - db.problem.findUnique.resolves(baseProblem) - - const result = await service.getProblem( - baseProblem.id, - strangerId, - Role.Admin - ) - - expect(result.myRole).to.equal(null) - }) - - it('lets anyone view a Published problem, even a stranger with no privilege', async () => { - db.problem.findUnique.resolves({ - ...baseProblem, - status: ProblemStatus.Published, - description: 'a real statement', - mandeuldangSolution: { id: 1 }, - mandeuldangTestFiles: [{ id: 1 }, { id: 2 }, { id: 3 }] - }) - - const result = await service.getProblem( - baseProblem.id, - strangerId, - Role.User - ) - - expect(result.testFileCount).to.equal(3) - expect(result.canPublish).to.equal(true) - expect(result.missingForPublish).to.deep.equal([]) - }) - - it('returns the actual test file list, not just a count', async () => { - const testFiles = [ - { id: 1, fileName: '1.in' }, - { id: 2, fileName: '1.out' } - ] - db.problem.findUnique.resolves({ - ...baseProblem, - mandeuldangTestFiles: testFiles - }) - - const result = await service.getProblem( - baseProblem.id, - ownerId, - Role.User - ) - - expect(result.mandeuldangTestFiles).to.deep.equal(testFiles) - }) - }) }) diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts index 3208337416..5da8e2c8d2 100644 --- a/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts +++ b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts @@ -1,172 +1,4 @@ import { Injectable } from '@nestjs/common' -import { - CollaboratorRole, - CollaboratorStatus, - ProblemCreationMode, - ProblemStatus, - Role -} from '@prisma/client' -import { - EntityNotExistException, - ForbiddenAccessException -} from '@libs/exception' -import { PrismaService } from '@libs/prisma' -import type { MandeuldangProblemOutput } from '../model/problem.output' - -/** 목록/상세 조회 모두에서 재사용하는, "요청자의 협업 역할" 계산 로직. */ -const resolveMyRole = ( - problem: { - createdById: number | null - mandeuldangCollaborators: Array<{ - userId: number - role: CollaboratorRole - status: CollaboratorStatus - }> - }, - userId: number -): `${CollaboratorRole}` | null => { - // Owner는 항상 Owner 권한을 갖는다 — MandeuldangCollaborator에 Owner 행이 아직 - // 없더라도(협업자 등록은 다른 작업에서 다룬다) createdById로 대체 판단한다. - if (problem.createdById === userId) { - return CollaboratorRole.Owner - } - const myCollaborator = problem.mandeuldangCollaborators.find( - (collaborator) => collaborator.userId === userId - ) - return myCollaborator?.role ?? null -} @Injectable() -export class MandeuldangProblemService { - constructor(private readonly prisma: PrismaService) {} - - /** - * 내가 만든(Owner인) 만들당 문제 목록. 기본적으로 상태와 무관하게 전부 보여준다. - * "management -> 내가 만든 문제" 화면용. `status`를 넘기면 그 상태로만 좁혀 조회한다. - */ - async getMyProblems( - userId: number, - cursor: number | null, - take: number, - status?: ProblemStatus - ): Promise { - const paginator = this.prisma.getPaginator(cursor) - const problems = await this.prisma.problem.findMany({ - ...paginator, - take, - where: { - creationMode: ProblemCreationMode.Mandeuldang, - createdById: userId, - ...(status && { status }) - }, - include: { - mandeuldangCollaborators: { where: { userId } } - }, - orderBy: { updateTime: 'desc' } - }) - return problems.map((problem) => ({ - ...problem, - myRole: resolveMyRole(problem, userId) - })) - } - - /** - * 제작 중인(기본적으로 아직 발행되지 않은) 만들당 문제 목록. - * Owner이거나 승인된(Approved) Collaborator로 참여 중인 문제를 모두 포함한다 — - * "내가 만든 문제"보다 넓은 범위다. "management -> 제작 중인 문제" 화면용. - * `status`를 넘기면 "Published가 아님" 대신 그 상태로만(Draft만, Ready만) 좁혀 조회한다. - */ - async getInProgressProblems( - userId: number, - cursor: number | null, - take: number, - status?: ProblemStatus - ): Promise { - const paginator = this.prisma.getPaginator(cursor) - const problems = await this.prisma.problem.findMany({ - ...paginator, - take, - where: { - creationMode: ProblemCreationMode.Mandeuldang, - status: status ?? { not: ProblemStatus.Published }, - OR: [ - { createdById: userId }, - { - mandeuldangCollaborators: { - some: { userId, status: CollaboratorStatus.Approved } - } - } - ] - }, - include: { - mandeuldangCollaborators: { where: { userId } } - }, - orderBy: { updateTime: 'desc' } - }) - return problems.map((problem) => ({ - ...problem, - myRole: resolveMyRole(problem, userId) - })) - } - - /** - * 문제 ID로 상세 조회. Statement, 발행 가능 여부, Solution/Tool/TestFile 목록, - * Collaborator 목록, 요청자의 역할을 한 번에 반환한다. - * - * 접근 권한: Published 문제는 (기존 Problem API와 동등하게) 누구나 조회 가능하다. - * 그 외(Draft/Ready)는 Owner·승인된 Collaborator·Admin/SuperAdmin만 조회할 수 있다. - */ - async getProblem( - id: number, - userId: number, - userRole: Role - ): Promise { - const problem = await this.prisma.problem.findUnique({ - where: { id }, - include: { - mandeuldangCollaborators: { include: { user: true } }, - mandeuldangSolution: true, - mandeuldangTools: true, - mandeuldangTestFiles: true - } - }) - - if (!problem || problem.creationMode !== ProblemCreationMode.Mandeuldang) { - throw new EntityNotExistException('MandeuldangProblem') - } - - const myCollaborator = problem.mandeuldangCollaborators.find( - (collaborator) => collaborator.userId === userId - ) - const isOwner = problem.createdById === userId - const isApprovedCollaborator = - myCollaborator?.status === CollaboratorStatus.Approved - const hasPrivilege = userRole === Role.Admin || userRole === Role.SuperAdmin - - const isVisible = - problem.status === ProblemStatus.Published || - isOwner || - isApprovedCollaborator || - hasPrivilege - - if (!isVisible) { - throw new ForbiddenAccessException( - 'Only the owner, an approved collaborator, or an admin can access a problem that is not yet published' - ) - } - - const testFileCount = problem.mandeuldangTestFiles.length - const missingForPublish: string[] = [] - if (!problem.description) missingForPublish.push('STATEMENT') - if (!problem.mandeuldangSolution) missingForPublish.push('SOLUTION') - if (testFileCount === 0) missingForPublish.push('TEST_FILES') - - return { - ...problem, - myRole: resolveMyRole(problem, userId), - testFileCount, - canPublish: missingForPublish.length === 0, - missingForPublish - } - } -} +export class MandeuldangProblemService {} diff --git a/apps/backend/apps/admin/src/problem/services/problem.service.spec.ts b/apps/backend/apps/admin/src/problem/services/problem.service.spec.ts index 5d8fd5571f..a709e6d3cc 100644 --- a/apps/backend/apps/admin/src/problem/services/problem.service.spec.ts +++ b/apps/backend/apps/admin/src/problem/services/problem.service.spec.ts @@ -106,22 +106,20 @@ describe('ProblemService', () => { }) describe('createProblem', () => { - // 이 mock의 필드들은 Problem 전체 타입(nullable)에서 왔지만 실제 값은 항상 - // 채워져 있다 — CreateProblemInput은 (레거시 문제 생성 시 그대로) non-null을 요구한다. const input = { title: problems[0].title, - description: problems[0].description!, - inputDescription: problems[0].inputDescription!, - outputDescription: problems[0].outputDescription!, - hint: problems[0].hint!, + description: problems[0].description, + inputDescription: problems[0].inputDescription, + outputDescription: problems[0].outputDescription, + hint: problems[0].hint, isVisible: false, template: problems[0].template, languages: problems[0].languages, solution: problems[0].solution, - timeLimit: problems[0].timeLimit!, - memoryLimit: problems[0].memoryLimit!, + timeLimit: problems[0].timeLimit, + memoryLimit: problems[0].memoryLimit, difficulty: Level.Level1, - source: problems[0].source!, + source: problems[0].source, testcases: [testcaseInput], tagIds: [1] } diff --git a/apps/backend/apps/admin/src/problem/services/problem.service.ts b/apps/backend/apps/admin/src/problem/services/problem.service.ts index a727725255..dde9c95f43 100644 --- a/apps/backend/apps/admin/src/problem/services/problem.service.ts +++ b/apps/backend/apps/admin/src/problem/services/problem.service.ts @@ -7,7 +7,7 @@ import { ProblemWhereInput, UpdateHistory } from '@generated' -import { ContestRole, ProblemField, ProblemStatus, Role } from '@prisma/client' +import { ContestRole, ProblemField, Role } from '@prisma/client' import { Workbook } from 'exceljs' import { Response } from 'express' import { Readable } from 'stream' @@ -297,10 +297,6 @@ export class ProblemService { const whereOptions: ProblemWhereInput = await this.buildProblemWhereOptionsWithMode(userId, mode, contestId) - // 만들당 Draft/Ready 문제는 전용 화면(제작 중인 문제)에서만 다룬다 — 기존 문제 목록/조회에는 - // 노출하지 않는다. 기존 Legacy 문제는 status가 항상 Published(스키마 기본값)라 영향이 없다. - whereOptions.status = { equals: ProblemStatus.Published } - if (input.difficulty) { whereOptions.difficulty = { in: input.difficulty @@ -405,9 +401,7 @@ export class ProblemService { async getProblem(id: number, userRole: Role, userId: number) { const problem = await this.prisma.problem.findFirstOrThrow({ where: { - id, - // 만들당 Draft/Ready 문제는 전용 화면에서만 조회한다 (getProblems와 동일한 정책). - status: ProblemStatus.Published + id }, include: { sharedGroups: true @@ -742,8 +736,7 @@ export class ProblemService { } // Problem description에 이미지가 포함되어 있다면 삭제 - // 만들당 Draft 문제는 description이 아직 없을 수 있다 (nullable) — 그 경우 추출할 이미지가 없다. - const uuidImageFileNames = this.extractUUIDs(problem.description ?? '') + const uuidImageFileNames = this.extractUUIDs(problem.description) if (uuidImageFileNames) { await this.prisma.file.deleteMany({ where: { diff --git a/apps/backend/apps/admin/src/submission/submission.service.ts b/apps/backend/apps/admin/src/submission/submission.service.ts index 73b6090e10..adf7be180d 100644 --- a/apps/backend/apps/admin/src/submission/submission.service.ts +++ b/apps/backend/apps/admin/src/submission/submission.service.ts @@ -19,7 +19,6 @@ import type { AuthenticatedUser } from '@libs/auth' import { EntityNotExistException, ForbiddenAccessException, - UnprocessableDataException, UnprocessableFileDataException } from '@libs/exception' import { PrismaService } from '@libs/prisma' @@ -959,17 +958,7 @@ export class SubmissionService { } } - // 만들당 Draft/Ready 문제는 timeLimit/memoryLimit이 아직 없을 수 있다(nullable). - // 재채점은 이 값을 Iris로 그대로 보내야 하므로, 없으면 여기서 명확히 막는다 — - // 원래는 발행 검증(publish validation)에서 걸러졌어야 할 상태다. - const { timeLimit, memoryLimit } = problem - if (timeLimit == null || memoryLimit == null) { - throw new UnprocessableDataException( - 'Problem is missing timeLimit/memoryLimit and cannot be rejudged' - ) - } - - return { assignment, problem: { ...problem, timeLimit, memoryLimit } } + return { assignment, problem } } /** diff --git a/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts b/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts index 4a4001505b..889145bc04 100644 --- a/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts +++ b/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts @@ -11,23 +11,20 @@ import { Exclude, Expose } from 'class-transformer' export class ProblemResponseDto { id: number title: string - // 만들당 Draft/Ready 문제는 이 필드들이 아직 채워지지 않을 수 있다(nullable). - // 이 DTO는 status=Published 문제만 반환하는 getProblem()에서 쓰이므로, 정상적으로는 - // 항상 값이 채워져 있어야 한다 — 다만 스키마 자체는 nullable이라 타입도 그에 맞춘다. - description: string | null - inputDescription: string | null - outputDescription: string | null - hint: string | null + description: string + inputDescription: string + outputDescription: string + hint: string engTitle: string | null engDescription: string | null engInputDescription: string | null engOutputDescription: string | null engHint: string | null languages: Language[] - timeLimit: number | null - memoryLimit: number | null - difficulty: Level | null - source: string | null + timeLimit: number + memoryLimit: number + difficulty: Level + source: string submissionCount: number acceptedCount: number acceptedRate: number diff --git a/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts b/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts index dc097d594e..4af944a966 100644 --- a/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts +++ b/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts @@ -10,7 +10,7 @@ class Problem { id: number title: string engTitle: string | null - difficulty: Level | null + difficulty: Level submissionCount: number acceptedRate: number tags: Partial[] diff --git a/apps/backend/apps/client/src/problem/problem.service.ts b/apps/backend/apps/client/src/problem/problem.service.ts index 2fda940833..354514ed07 100644 --- a/apps/backend/apps/client/src/problem/problem.service.ts +++ b/apps/backend/apps/client/src/problem/problem.service.ts @@ -1,5 +1,5 @@ import { ForbiddenException, Injectable } from '@nestjs/common' -import { Prisma, ProblemStatus, ResultStatus } from '@prisma/client' +import { Prisma, ResultStatus } from '@prisma/client' import type { Decimal } from '@prisma/client/runtime/library' import { MIN_DATE } from '@libs/constants' import { ForbiddenAccessException } from '@libs/exception' @@ -108,10 +108,7 @@ export class ProblemService { // 아니면 텍스트가 많은 field에서는 full-text search를 사용하고, 텍스트가 적은 field에서는 contains를 사용하는 방법도 고려해보자. contains: search }, - visibleLockTime: MIN_DATE, - // 만들당 Draft/Ready 문제는 학생에게 아직 공개되면 안 된다. - // 레거시 문제는 status가 항상 Published(스키마 기본값)라 영향 없다. - status: ProblemStatus.Published + visibleLockTime: MIN_DATE }, select: { ...problemsSelectOption, @@ -218,9 +215,7 @@ export class ProblemService { const data = await this.prisma.problem.findUniqueOrThrow({ where: { id: problemId, - visibleLockTime: MIN_DATE, - // 만들당 Draft/Ready 문제는 학생에게 아직 공개되면 안 된다. - status: ProblemStatus.Published + visibleLockTime: MIN_DATE }, select: problemSelectOption }) diff --git a/apps/backend/apps/client/src/submission/submission-pub.service.ts b/apps/backend/apps/client/src/submission/submission-pub.service.ts index 6f8e20a394..fb756aad3b 100644 --- a/apps/backend/apps/client/src/submission/submission-pub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-pub.service.ts @@ -2,10 +2,7 @@ import { Injectable } from '@nestjs/common' import type { Submission, TestSubmission } from '@prisma/client' import { Span } from 'nestjs-otel' import { JudgeAMQPService } from '@libs/amqp' -import { - EntityNotExistException, - UnprocessableDataException -} from '@libs/exception' +import { EntityNotExistException } from '@libs/exception' import { PrismaService } from '@libs/prisma' import { Snippet } from './class/create-submission.dto' import { JudgeRequest, UserTestcaseJudgeRequest } from './class/judge-request' @@ -72,29 +69,18 @@ export class SubmissionPublicationService { throw new EntityNotExistException('Problem') } - // 만들당 Draft/Ready 문제는 timeLimit/memoryLimit이 아직 없을 수 있다(nullable) — - // 채점 요청을 만들려면 이 값이 반드시 있어야 하므로 여기서 명확히 막는다. - // 원래는 발행 검증(publish validation)에서 걸러졌어야 할 상태다. - const { timeLimit, memoryLimit } = problem - if (timeLimit == null || memoryLimit == null) { - throw new UnprocessableDataException( - 'Problem is missing timeLimit/memoryLimit and cannot be judged' - ) - } - const judgeableProblem = { ...problem, timeLimit, memoryLimit } - const judgeRequest = isUserTest ? new UserTestcaseJudgeRequest( code, submission.language, - judgeableProblem, + problem, userTestcases!, stopOnNotAccepted ) : new JudgeRequest( code, submission.language, - judgeableProblem, + problem, stopOnNotAccepted, judgeOnlyHiddenTestcases, containHiddenTestcases diff --git a/apps/backend/apps/client/src/submission/submission.service.ts b/apps/backend/apps/client/src/submission/submission.service.ts index f6a84ea8b0..e47e7a12dd 100644 --- a/apps/backend/apps/client/src/submission/submission.service.ts +++ b/apps/backend/apps/client/src/submission/submission.service.ts @@ -7,7 +7,6 @@ import { Language, Prisma, Problem, - ProblemStatus, ResultStatus, Role, Submission, @@ -87,10 +86,7 @@ export class SubmissionService { id: problemId, visibleLockTime: { equals: MIN_DATE - }, - // 만들당 Draft/Ready 문제는 발행 전이라 제출을 받으면 안 된다. - // 레거시 문제는 status가 항상 Published(스키마 기본값)라 영향 없다. - status: ProblemStatus.Published + } } }) if (!problem) { @@ -210,10 +206,6 @@ export class SubmissionService { throw new EntityNotExistException('ContestProblem') } const { problem } = contestProblem - // 만들당 Draft/Ready 문제는 발행 전이라 제출을 받으면 안 된다. - if (problem.status !== ProblemStatus.Published) { - throw new EntityNotExistException('Problem') - } const submission = await this.createSubmission({ submissionDto, @@ -340,10 +332,6 @@ export class SubmissionService { throw new EntityNotExistException('AssignmentProblem') } const { problem } = assignmentProblem - // 만들당 Draft/Ready 문제는 발행 전이라 제출을 받으면 안 된다. - if (problem.status !== ProblemStatus.Published) { - throw new EntityNotExistException('Problem') - } await this.prisma.assignmentProblemRecord.upsert({ where: { @@ -427,11 +415,7 @@ export class SubmissionService { throw new EntityNotExistException('WorkbookProblem') } const { problem } = workbookProblem - // 만들당 Draft/Ready 문제는 발행 전이라 제출을 받으면 안 된다. - if ( - problem.visibleLockTime.getTime() !== MIN_DATE.getTime() || - problem.status !== ProblemStatus.Published - ) { + if (problem.visibleLockTime.getTime() !== MIN_DATE.getTime()) { throw new EntityNotExistException('Problem') } diff --git a/apps/backend/apps/client/src/submission/test/submission-pub.service.spec.ts b/apps/backend/apps/client/src/submission/test/submission-pub.service.spec.ts index 717bfa5caa..efba19a0f2 100644 --- a/apps/backend/apps/client/src/submission/test/submission-pub.service.spec.ts +++ b/apps/backend/apps/client/src/submission/test/submission-pub.service.spec.ts @@ -27,15 +27,6 @@ const submission: Submission & { submissionResult: SubmissionResult[] } = { score: new Prisma.Decimal(100) } -// problems[0]는 Problem 전체 타입이라 timeLimit/memoryLimit이 number | null이다 — -// 이 mock은 실제로 항상 값이 채워져 있으므로, JudgeRequest 생성자가 요구하는 -// non-null 타입에 맞춰 좁혀둔다. -const judgeableProblem = { - ...problems[0], - timeLimit: problems[0].timeLimit!, - memoryLimit: problems[0].memoryLimit! -} - describe('SubmissionPublicationService', () => { let service: SubmissionPublicationService let amqpService: JudgeAMQPService @@ -85,7 +76,7 @@ describe('SubmissionPublicationService', () => { const judgeRequest = new JudgeRequest( submissions[0].code, submission.language, - judgeableProblem + problems[0] ) await expect( @@ -121,7 +112,7 @@ describe('SubmissionPublicationService', () => { const judgeRequest = new JudgeRequest( submissions[0].code, submission.language, - judgeableProblem + problems[0] ) await expect( @@ -173,7 +164,7 @@ describe('SubmissionPublicationService', () => { const userTestcaseJudgeRequest = new UserTestcaseJudgeRequest( submissions[0].code, submission.language, - judgeableProblem, + problems[0], userTestcases, true ) @@ -220,7 +211,7 @@ describe('SubmissionPublicationService', () => { const judgeRequest = new JudgeRequest( submissions[0].code, submission.language, - judgeableProblem, + problems[0], true, true, true diff --git a/apps/backend/apps/client/src/submission/test/submission.service.spec.ts b/apps/backend/apps/client/src/submission/test/submission.service.spec.ts index a785f7bd6e..0e8be95993 100644 --- a/apps/backend/apps/client/src/submission/test/submission.service.spec.ts +++ b/apps/backend/apps/client/src/submission/test/submission.service.spec.ts @@ -4,7 +4,7 @@ import { ConfigService } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import { Test } from '@nestjs/testing' import type { Contest, User, Assignment } from '@prisma/client' -import { Language, ProblemStatus, Role } from '@prisma/client' +import { Language, Role } from '@prisma/client' import type { Cache } from 'cache-manager' import { expect } from 'chai' import { plainToInstance } from 'class-transformer' @@ -244,24 +244,6 @@ describe('SubmissionService', () => { ).to.be.rejectedWith(EntityNotExistException) expect(createSpy.called).to.be.false }) - - it('should only look up Published problems (excludes Draft/Ready mandeuldang problems)', async () => { - db.problem.findFirst.resolves(problems[0]) - stub(service, 'createSubmission') - - await service.submitToProblem({ - submissionDto, - userIp: USERIP, - userId: submissions[0].userId, - problemId: problems[0].id - }) - - expect( - db.problem.findFirst.calledWithMatch({ - where: { status: ProblemStatus.Published } - }) - ).to.be.true - }) }) describe('submitToContest', () => { @@ -303,33 +285,6 @@ describe('SubmissionService', () => { ).to.be.rejectedWith(EntityNotExistException) expect(createSpy.called).to.be.false }) - - it('should throw exception if the problem is not yet published', async () => { - const createSpy = stub(service, 'createSubmission') - db.contest.findFirst.resolves(mockContest) - db.userContest.findFirst.resolves(null) - db.contestRecord.findUnique.resolves({ - contest: { - groupId: 1, - startTime: new Date(Date.now() - 10000), - endTime: new Date(Date.now() + 10000) - } - }) - db.contestProblem.findUnique.resolves({ - problem: { ...problems[0], status: ProblemStatus.Draft } - }) - - await expect( - service.submitToContest({ - submissionDto, - userIp: USERIP, - userId: submissions[0].userId, - problemId: problems[0].id, - contestId: CONTEST_ID - }) - ).to.be.rejectedWith(EntityNotExistException) - expect(createSpy.called).to.be.false - }) }) describe('submitToAssignment', () => { @@ -372,34 +327,6 @@ describe('SubmissionService', () => { ).to.be.rejectedWith(EntityNotExistException) expect(createSpy.called).to.be.false }) - - it('should throw exception if the problem is not yet published', async () => { - const createSpy = stub(service, 'createSubmission') - db.assignment.findFirst.resolves(mockAssignment) - db.assignmentRecord.findUnique.resolves({ - id: 1, - assignment: { - groupId: 1, - startTime: new Date(Date.now() - 10000), - endTime: new Date(Date.now() + 10000), - dueTime: new Date(Date.now() + 5000) - } - }) - db.assignmentProblem.findUnique.resolves({ - problem: { ...problems[0], status: ProblemStatus.Draft } - }) - - await expect( - service.submitToAssignment({ - submissionDto, - userIp: USERIP, - userId: submissions[0].userId, - problemId: problems[0].id, - assignmentId: ASSIGNMENT_ID - }) - ).to.be.rejectedWith(EntityNotExistException) - expect(createSpy.called).to.be.false - }) }) describe('submitToWorkbook', () => { @@ -432,24 +359,6 @@ describe('SubmissionService', () => { ).to.be.rejectedWith(EntityNotExistException) expect(createSpy.called).to.be.false }) - - it('should throw exception if the problem is not yet published', async () => { - const createSpy = stub(service, 'createSubmission') - db.workbookProblem.findUnique.resolves({ - problem: { ...problems[0], status: ProblemStatus.Draft } - }) - - await expect( - service.submitToWorkbook({ - submissionDto, - userIp: USERIP, - userId: submissions[0].userId, - problemId: problems[0].id, - workbookId: WORKBOOK_ID - }) - ).to.be.rejectedWith(EntityNotExistException) - expect(createSpy.called).to.be.false - }) }) describe('createSubmission', () => { From 0652c806463b264e1f7789b681f26b420cf7e8ff Mon Sep 17 00:00:00 2001 From: han25-ya Date: Wed, 2 Sep 2026 22:32:07 +0900 Subject: [PATCH 7/8] Reapply "feat(be): implement Mandeuldang problem read queries and legacy compatibility (#3732)" This reverts commit 1503f5530302034aeccf47ce3f8a1346bd48e1e2. --- .../problem/model/problem.output.ts | 49 +++ .../resolvers/problem.resolver.spec.ts | 73 ++++- .../problem/resolvers/problem.resolver.ts | 56 +++- .../problem/services/problem.service.spec.ts | 284 +++++++++++++++++- .../problem/services/problem.service.ts | 170 ++++++++++- .../problem/services/problem.service.spec.ts | 16 +- .../src/problem/services/problem.service.ts | 13 +- .../src/submission/submission.service.ts | 13 +- .../src/problem/dto/problem.response.dto.ts | 19 +- .../src/problem/dto/problems.response.dto.ts | 2 +- .../client/src/problem/problem.service.ts | 11 +- .../src/submission/submission-pub.service.ts | 20 +- .../src/submission/submission.service.ts | 20 +- .../test/submission-pub.service.spec.ts | 17 +- .../test/submission.service.spec.ts | 93 +++++- 15 files changed, 817 insertions(+), 39 deletions(-) create mode 100644 apps/backend/apps/admin/src/mandeuldang/problem/model/problem.output.ts diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/model/problem.output.ts b/apps/backend/apps/admin/src/mandeuldang/problem/model/problem.output.ts new file mode 100644 index 0000000000..00d03aeb27 --- /dev/null +++ b/apps/backend/apps/admin/src/mandeuldang/problem/model/problem.output.ts @@ -0,0 +1,49 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' +import { CollaboratorRole, Problem } from '@admin/@generated' + +/** + * 만들당 문제 상세/목록 조회 결과. + * + * DB는 MandeuldangProblem이라는 별도 모델 없이 기존 Problem을 그대로 쓰기로 결정됐으므로 + * (백엔드 회의 08.20 결론), 자동 생성된 Problem GraphQL 타입을 그대로 확장한다 — + * ProblemWithIsVisible(../../problem/model/problem.output.ts)이 이미 같은 패턴을 쓰고 있다. + * + * 목록 전용 Output 타입은 따로 만들지 않았다. 목록 조회에서는 아래 관계·계산 필드를 + * 채우지 않고 undefined로 두면 되므로(전부 nullable), 상세 조회와 타입을 공유해도 + * 계약이 깨지지 않는다. + */ +@ObjectType() +export class MandeuldangProblemOutput extends Problem { + @Field(() => CollaboratorRole, { + nullable: true, + description: + '요청한 사용자가 이 문제에 대해 가진 협업 역할. Owner/Editor/Reviewer가 아니면 null.' + }) + myRole?: `${CollaboratorRole}` | null + + // mandeuldangCollaborators/mandeuldangSolution/mandeuldangTools는 기존 생성된 + // Problem 타입에 이미 관계 필드로 선언돼 있어(부모 필드) 여기서 다시 선언하지 않는다 — + // 서비스가 Prisma include로 채워 넣은 값이 그대로 상속된 필드에 실린다. + + @Field(() => Int, { + nullable: true, + description: '등록된 테스트 파일(.in/.out 쌍 기준이 아니라 개별 파일 개수)' + }) + testFileCount?: number + + @Field(() => Boolean, { + nullable: true, + description: + '지금 상태로 발행 가능한지 여부. 상세 조회에서만 계산해 채운다.' + }) + canPublish?: boolean + + @Field(() => [String], { + nullable: true, + description: + 'canPublish가 false일 때 무엇이 부족한지 나타내는 코드 목록 ' + + '(STATEMENT/SOLUTION/TEST_FILES). 실제 발행 가능 여부의 최종 판단과 발행 자체는 ' + + 'Update/발행 담당 쪽에서 이뤄지므로, 이 값은 참고용 미리보기다.' + }) + missingForPublish?: string[] +} diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts index 5b971564f7..c3b5f54a78 100644 --- a/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts +++ b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.spec.ts @@ -1,13 +1,34 @@ import { Test, type TestingModule } from '@nestjs/testing' +import { ProblemStatus, Role } from '@prisma/client' import { expect } from 'chai' +import { stub } from 'sinon' +import type { AuthenticatedRequest } from '@libs/auth' +import { MandeuldangProblemService } from '../services/problem.service' import { MandeuldangProblemResolver } from './problem.resolver' +const problemService = { + getMyProblems: stub(), + getInProgressProblems: stub(), + getProblem: stub() +} + +const req = { + user: { id: 1, role: Role.User } +} as unknown as AuthenticatedRequest + describe('MandeuldangProblemResolver', () => { let resolver: MandeuldangProblemResolver beforeEach(async () => { + problemService.getMyProblems.reset() + problemService.getInProgressProblems.reset() + problemService.getProblem.reset() + const module: TestingModule = await Test.createTestingModule({ - providers: [MandeuldangProblemResolver] + providers: [ + MandeuldangProblemResolver, + { provide: MandeuldangProblemService, useValue: problemService } + ] }).compile() resolver = module.get( @@ -18,4 +39,54 @@ describe('MandeuldangProblemResolver', () => { it('should be defined', () => { expect(resolver).to.be.ok }) + + it('getMyMandeuldangProblems delegates to the service with the requester id', async () => { + problemService.getMyProblems.resolves([]) + + await resolver.getMyMandeuldangProblems(req, null, 10, undefined) + + expect( + problemService.getMyProblems.calledOnceWith(1, null, 10, undefined) + ).to.equal(true) + }) + + it('getMyMandeuldangProblems forwards an explicit status filter', async () => { + problemService.getMyProblems.resolves([]) + + await resolver.getMyMandeuldangProblems(req, null, 10, ProblemStatus.Draft) + + expect( + problemService.getMyProblems.calledOnceWith( + 1, + null, + 10, + ProblemStatus.Draft + ) + ).to.equal(true) + }) + + it('getInProgressMandeuldangProblems delegates to the service with the requester id', async () => { + problemService.getInProgressProblems.resolves([]) + + await resolver.getInProgressMandeuldangProblems(req, null, 10, undefined) + + expect( + problemService.getInProgressProblems.calledOnceWith( + 1, + null, + 10, + undefined + ) + ).to.equal(true) + }) + + it('getMandeuldangProblem delegates to the service with id, requester id, and role', async () => { + problemService.getProblem.resolves({}) + + await resolver.getMandeuldangProblem(req, 42) + + expect(problemService.getProblem.calledOnceWith(42, 1, Role.User)).to.equal( + true + ) + }) }) diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts index 5fe123b7a2..ae5f1df5b2 100644 --- a/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts +++ b/apps/backend/apps/admin/src/mandeuldang/problem/resolvers/problem.resolver.ts @@ -1,4 +1,54 @@ -import { Resolver } from '@nestjs/graphql' +import { Args, Context, Int, Query, Resolver } from '@nestjs/graphql' +import { ProblemStatus } from '@prisma/client' +import { AuthenticatedRequest, UseDisableAdminGuard } from '@libs/auth' +import { CursorValidationPipe, RequiredIntPipe } from '@libs/pipe' +import { MandeuldangProblemOutput } from '../model/problem.output' +import { MandeuldangProblemService } from '../services/problem.service' -@Resolver() -export class MandeuldangProblemResolver {} +@Resolver(() => MandeuldangProblemOutput) +@UseDisableAdminGuard() +export class MandeuldangProblemResolver { + constructor(private readonly problemService: MandeuldangProblemService) {} + + @Query(() => [MandeuldangProblemOutput]) + async getMyMandeuldangProblems( + @Context('req') req: AuthenticatedRequest, + @Args('cursor', { nullable: true, type: () => Int }, CursorValidationPipe) + cursor: number | null, + @Args('take', { defaultValue: 10, type: () => Int }) take: number, + @Args('status', { nullable: true, type: () => ProblemStatus }) + status?: ProblemStatus + ) { + return await this.problemService.getMyProblems( + req.user.id, + cursor, + take, + status + ) + } + + @Query(() => [MandeuldangProblemOutput]) + async getInProgressMandeuldangProblems( + @Context('req') req: AuthenticatedRequest, + @Args('cursor', { nullable: true, type: () => Int }, CursorValidationPipe) + cursor: number | null, + @Args('take', { defaultValue: 10, type: () => Int }) take: number, + @Args('status', { nullable: true, type: () => ProblemStatus }) + status?: ProblemStatus + ) { + return await this.problemService.getInProgressProblems( + req.user.id, + cursor, + take, + status + ) + } + + @Query(() => MandeuldangProblemOutput) + async getMandeuldangProblem( + @Context('req') req: AuthenticatedRequest, + @Args('id', { type: () => Int }, new RequiredIntPipe('id')) id: number + ) { + return await this.problemService.getProblem(id, req.user.id, req.user.role) + } +} diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts index 540f51b3a8..499e8b9270 100644 --- a/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts +++ b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.spec.ts @@ -1,13 +1,56 @@ import { Test, type TestingModule } from '@nestjs/testing' +import { + CollaboratorRole, + CollaboratorStatus, + ProblemCreationMode, + ProblemStatus, + Role +} from '@prisma/client' import { expect } from 'chai' +import { stub } from 'sinon' +import { PrismaService } from '@libs/prisma' import { MandeuldangProblemService } from './problem.service' +const ownerId = 1 +const collaboratorId = 2 +const strangerId = 3 + +const baseProblem = { + id: 10, + createdById: ownerId, + creationMode: ProblemCreationMode.Mandeuldang, + status: ProblemStatus.Draft, + description: null, + mandeuldangCollaborators: [] as Array<{ + userId: number + role: CollaboratorRole + status: CollaboratorStatus + }>, + mandeuldangSolution: null, + mandeuldangTools: [], + mandeuldangTestFiles: [] as unknown[] +} + +const db = { + problem: { + findMany: stub(), + findUnique: stub() + }, + getPaginator: PrismaService.prototype.getPaginator +} + describe('MandeuldangProblemService', () => { let service: MandeuldangProblemService beforeEach(async () => { + db.problem.findMany.reset() + db.problem.findUnique.reset() + const module: TestingModule = await Test.createTestingModule({ - providers: [MandeuldangProblemService] + providers: [ + MandeuldangProblemService, + { provide: PrismaService, useValue: db } + ] }).compile() service = module.get(MandeuldangProblemService) @@ -16,4 +59,243 @@ describe('MandeuldangProblemService', () => { it('should be defined', () => { expect(service).to.be.ok }) + + describe('getMyProblems', () => { + it('filters by creationMode=Mandeuldang and createdById, regardless of status by default', async () => { + db.problem.findMany.resolves([baseProblem]) + + await service.getMyProblems(ownerId, null, 10) + + const call = db.problem.findMany.firstCall.args[0] + expect(call.where).to.deep.equal({ + creationMode: ProblemCreationMode.Mandeuldang, + createdById: ownerId + }) + }) + + it('narrows to a specific status when one is given', async () => { + db.problem.findMany.resolves([]) + + await service.getMyProblems(ownerId, null, 10, ProblemStatus.Ready) + + const call = db.problem.findMany.firstCall.args[0] + expect(call.where.status).to.equal(ProblemStatus.Ready) + }) + + it('reports myRole=Owner on every item in the list', async () => { + db.problem.findMany.resolves([baseProblem]) + + const result = await service.getMyProblems(ownerId, null, 10) + + expect(result[0].myRole).to.equal(CollaboratorRole.Owner) + }) + }) + + describe('getInProgressProblems', () => { + it('excludes Published problems and includes owner OR approved-collaborator scope by default', async () => { + db.problem.findMany.resolves([]) + + await service.getInProgressProblems(ownerId, null, 10) + + const call = db.problem.findMany.firstCall.args[0] + expect(call.where.status).to.deep.equal({ not: ProblemStatus.Published }) + expect(call.where.OR).to.deep.equal([ + { createdById: ownerId }, + { + mandeuldangCollaborators: { + some: { userId: ownerId, status: CollaboratorStatus.Approved } + } + } + ]) + }) + + it('narrows to a specific status when one is given, instead of "not Published"', async () => { + db.problem.findMany.resolves([]) + + await service.getInProgressProblems( + ownerId, + null, + 10, + ProblemStatus.Draft + ) + + const call = db.problem.findMany.firstCall.args[0] + expect(call.where.status).to.equal(ProblemStatus.Draft) + }) + + it('reports myRole for a collaborator entry, distinct from owned entries', async () => { + const ownedItem = { ...baseProblem, id: 1, createdById: collaboratorId } + const collaboratingItem = { + ...baseProblem, + id: 2, + createdById: ownerId, + mandeuldangCollaborators: [ + { + userId: collaboratorId, + role: CollaboratorRole.Reviewer, + status: CollaboratorStatus.Approved + } + ] + } + db.problem.findMany.resolves([ownedItem, collaboratingItem]) + + const result = await service.getInProgressProblems( + collaboratorId, + null, + 10 + ) + + expect(result[0].myRole).to.equal(CollaboratorRole.Owner) + expect(result[1].myRole).to.equal(CollaboratorRole.Reviewer) + }) + }) + + describe('getProblem', () => { + it('throws EntityNotExistException when the problem does not exist', async () => { + db.problem.findUnique.resolves(null) + + try { + await service.getProblem(999, ownerId, Role.User) + expect.fail('should have thrown') + } catch (err) { + expect((err as Error).message).to.include('MandeuldangProblem') + } + }) + + it('throws EntityNotExistException for a Legacy (non-Mandeuldang) problem', async () => { + db.problem.findUnique.resolves({ + ...baseProblem, + creationMode: ProblemCreationMode.Legacy + }) + + try { + await service.getProblem(baseProblem.id, ownerId, Role.User) + expect.fail('should have thrown') + } catch (err) { + expect((err as Error).message).to.include('MandeuldangProblem') + } + }) + + it('allows the owner to view their own Draft problem and reports myRole=Owner', async () => { + db.problem.findUnique.resolves(baseProblem) + + const result = await service.getProblem( + baseProblem.id, + ownerId, + Role.User + ) + + expect(result.myRole).to.equal(CollaboratorRole.Owner) + expect(result.testFileCount).to.equal(0) + expect(result.canPublish).to.equal(false) + expect(result.missingForPublish).to.include.members([ + 'STATEMENT', + 'SOLUTION', + 'TEST_FILES' + ]) + }) + + it('allows an approved collaborator to view a Draft problem they do not own', async () => { + db.problem.findUnique.resolves({ + ...baseProblem, + mandeuldangCollaborators: [ + { + userId: collaboratorId, + role: CollaboratorRole.Editor, + status: CollaboratorStatus.Approved + } + ] + }) + + const result = await service.getProblem( + baseProblem.id, + collaboratorId, + Role.User + ) + + expect(result.myRole).to.equal(CollaboratorRole.Editor) + }) + + it('rejects a pending (not yet approved) collaborator from viewing a Draft problem', async () => { + db.problem.findUnique.resolves({ + ...baseProblem, + mandeuldangCollaborators: [ + { + userId: collaboratorId, + role: CollaboratorRole.Editor, + status: CollaboratorStatus.Pending + } + ] + }) + + try { + await service.getProblem(baseProblem.id, collaboratorId, Role.User) + expect.fail('should have thrown') + } catch (err) { + expect((err as Error).message).to.include('Only the owner') + } + }) + + it('rejects an unrelated user from viewing a Draft problem', async () => { + db.problem.findUnique.resolves(baseProblem) + + try { + await service.getProblem(baseProblem.id, strangerId, Role.User) + expect.fail('should have thrown') + } catch (err) { + expect((err as Error).message).to.include('Only the owner') + } + }) + + it('lets an Admin view a Draft problem they neither own nor collaborate on', async () => { + db.problem.findUnique.resolves(baseProblem) + + const result = await service.getProblem( + baseProblem.id, + strangerId, + Role.Admin + ) + + expect(result.myRole).to.equal(null) + }) + + it('lets anyone view a Published problem, even a stranger with no privilege', async () => { + db.problem.findUnique.resolves({ + ...baseProblem, + status: ProblemStatus.Published, + description: 'a real statement', + mandeuldangSolution: { id: 1 }, + mandeuldangTestFiles: [{ id: 1 }, { id: 2 }, { id: 3 }] + }) + + const result = await service.getProblem( + baseProblem.id, + strangerId, + Role.User + ) + + expect(result.testFileCount).to.equal(3) + expect(result.canPublish).to.equal(true) + expect(result.missingForPublish).to.deep.equal([]) + }) + + it('returns the actual test file list, not just a count', async () => { + const testFiles = [ + { id: 1, fileName: '1.in' }, + { id: 2, fileName: '1.out' } + ] + db.problem.findUnique.resolves({ + ...baseProblem, + mandeuldangTestFiles: testFiles + }) + + const result = await service.getProblem( + baseProblem.id, + ownerId, + Role.User + ) + + expect(result.mandeuldangTestFiles).to.deep.equal(testFiles) + }) + }) }) diff --git a/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts index 5da8e2c8d2..3208337416 100644 --- a/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts +++ b/apps/backend/apps/admin/src/mandeuldang/problem/services/problem.service.ts @@ -1,4 +1,172 @@ import { Injectable } from '@nestjs/common' +import { + CollaboratorRole, + CollaboratorStatus, + ProblemCreationMode, + ProblemStatus, + Role +} from '@prisma/client' +import { + EntityNotExistException, + ForbiddenAccessException +} from '@libs/exception' +import { PrismaService } from '@libs/prisma' +import type { MandeuldangProblemOutput } from '../model/problem.output' + +/** 목록/상세 조회 모두에서 재사용하는, "요청자의 협업 역할" 계산 로직. */ +const resolveMyRole = ( + problem: { + createdById: number | null + mandeuldangCollaborators: Array<{ + userId: number + role: CollaboratorRole + status: CollaboratorStatus + }> + }, + userId: number +): `${CollaboratorRole}` | null => { + // Owner는 항상 Owner 권한을 갖는다 — MandeuldangCollaborator에 Owner 행이 아직 + // 없더라도(협업자 등록은 다른 작업에서 다룬다) createdById로 대체 판단한다. + if (problem.createdById === userId) { + return CollaboratorRole.Owner + } + const myCollaborator = problem.mandeuldangCollaborators.find( + (collaborator) => collaborator.userId === userId + ) + return myCollaborator?.role ?? null +} @Injectable() -export class MandeuldangProblemService {} +export class MandeuldangProblemService { + constructor(private readonly prisma: PrismaService) {} + + /** + * 내가 만든(Owner인) 만들당 문제 목록. 기본적으로 상태와 무관하게 전부 보여준다. + * "management -> 내가 만든 문제" 화면용. `status`를 넘기면 그 상태로만 좁혀 조회한다. + */ + async getMyProblems( + userId: number, + cursor: number | null, + take: number, + status?: ProblemStatus + ): Promise { + const paginator = this.prisma.getPaginator(cursor) + const problems = await this.prisma.problem.findMany({ + ...paginator, + take, + where: { + creationMode: ProblemCreationMode.Mandeuldang, + createdById: userId, + ...(status && { status }) + }, + include: { + mandeuldangCollaborators: { where: { userId } } + }, + orderBy: { updateTime: 'desc' } + }) + return problems.map((problem) => ({ + ...problem, + myRole: resolveMyRole(problem, userId) + })) + } + + /** + * 제작 중인(기본적으로 아직 발행되지 않은) 만들당 문제 목록. + * Owner이거나 승인된(Approved) Collaborator로 참여 중인 문제를 모두 포함한다 — + * "내가 만든 문제"보다 넓은 범위다. "management -> 제작 중인 문제" 화면용. + * `status`를 넘기면 "Published가 아님" 대신 그 상태로만(Draft만, Ready만) 좁혀 조회한다. + */ + async getInProgressProblems( + userId: number, + cursor: number | null, + take: number, + status?: ProblemStatus + ): Promise { + const paginator = this.prisma.getPaginator(cursor) + const problems = await this.prisma.problem.findMany({ + ...paginator, + take, + where: { + creationMode: ProblemCreationMode.Mandeuldang, + status: status ?? { not: ProblemStatus.Published }, + OR: [ + { createdById: userId }, + { + mandeuldangCollaborators: { + some: { userId, status: CollaboratorStatus.Approved } + } + } + ] + }, + include: { + mandeuldangCollaborators: { where: { userId } } + }, + orderBy: { updateTime: 'desc' } + }) + return problems.map((problem) => ({ + ...problem, + myRole: resolveMyRole(problem, userId) + })) + } + + /** + * 문제 ID로 상세 조회. Statement, 발행 가능 여부, Solution/Tool/TestFile 목록, + * Collaborator 목록, 요청자의 역할을 한 번에 반환한다. + * + * 접근 권한: Published 문제는 (기존 Problem API와 동등하게) 누구나 조회 가능하다. + * 그 외(Draft/Ready)는 Owner·승인된 Collaborator·Admin/SuperAdmin만 조회할 수 있다. + */ + async getProblem( + id: number, + userId: number, + userRole: Role + ): Promise { + const problem = await this.prisma.problem.findUnique({ + where: { id }, + include: { + mandeuldangCollaborators: { include: { user: true } }, + mandeuldangSolution: true, + mandeuldangTools: true, + mandeuldangTestFiles: true + } + }) + + if (!problem || problem.creationMode !== ProblemCreationMode.Mandeuldang) { + throw new EntityNotExistException('MandeuldangProblem') + } + + const myCollaborator = problem.mandeuldangCollaborators.find( + (collaborator) => collaborator.userId === userId + ) + const isOwner = problem.createdById === userId + const isApprovedCollaborator = + myCollaborator?.status === CollaboratorStatus.Approved + const hasPrivilege = userRole === Role.Admin || userRole === Role.SuperAdmin + + const isVisible = + problem.status === ProblemStatus.Published || + isOwner || + isApprovedCollaborator || + hasPrivilege + + if (!isVisible) { + throw new ForbiddenAccessException( + 'Only the owner, an approved collaborator, or an admin can access a problem that is not yet published' + ) + } + + const testFileCount = problem.mandeuldangTestFiles.length + const missingForPublish: string[] = [] + if (!problem.description) missingForPublish.push('STATEMENT') + if (!problem.mandeuldangSolution) missingForPublish.push('SOLUTION') + if (testFileCount === 0) missingForPublish.push('TEST_FILES') + + return { + ...problem, + myRole: resolveMyRole(problem, userId), + testFileCount, + canPublish: missingForPublish.length === 0, + missingForPublish + } + } +} diff --git a/apps/backend/apps/admin/src/problem/services/problem.service.spec.ts b/apps/backend/apps/admin/src/problem/services/problem.service.spec.ts index a709e6d3cc..5d8fd5571f 100644 --- a/apps/backend/apps/admin/src/problem/services/problem.service.spec.ts +++ b/apps/backend/apps/admin/src/problem/services/problem.service.spec.ts @@ -106,20 +106,22 @@ describe('ProblemService', () => { }) describe('createProblem', () => { + // 이 mock의 필드들은 Problem 전체 타입(nullable)에서 왔지만 실제 값은 항상 + // 채워져 있다 — CreateProblemInput은 (레거시 문제 생성 시 그대로) non-null을 요구한다. const input = { title: problems[0].title, - description: problems[0].description, - inputDescription: problems[0].inputDescription, - outputDescription: problems[0].outputDescription, - hint: problems[0].hint, + description: problems[0].description!, + inputDescription: problems[0].inputDescription!, + outputDescription: problems[0].outputDescription!, + hint: problems[0].hint!, isVisible: false, template: problems[0].template, languages: problems[0].languages, solution: problems[0].solution, - timeLimit: problems[0].timeLimit, - memoryLimit: problems[0].memoryLimit, + timeLimit: problems[0].timeLimit!, + memoryLimit: problems[0].memoryLimit!, difficulty: Level.Level1, - source: problems[0].source, + source: problems[0].source!, testcases: [testcaseInput], tagIds: [1] } diff --git a/apps/backend/apps/admin/src/problem/services/problem.service.ts b/apps/backend/apps/admin/src/problem/services/problem.service.ts index dde9c95f43..a727725255 100644 --- a/apps/backend/apps/admin/src/problem/services/problem.service.ts +++ b/apps/backend/apps/admin/src/problem/services/problem.service.ts @@ -7,7 +7,7 @@ import { ProblemWhereInput, UpdateHistory } from '@generated' -import { ContestRole, ProblemField, Role } from '@prisma/client' +import { ContestRole, ProblemField, ProblemStatus, Role } from '@prisma/client' import { Workbook } from 'exceljs' import { Response } from 'express' import { Readable } from 'stream' @@ -297,6 +297,10 @@ export class ProblemService { const whereOptions: ProblemWhereInput = await this.buildProblemWhereOptionsWithMode(userId, mode, contestId) + // 만들당 Draft/Ready 문제는 전용 화면(제작 중인 문제)에서만 다룬다 — 기존 문제 목록/조회에는 + // 노출하지 않는다. 기존 Legacy 문제는 status가 항상 Published(스키마 기본값)라 영향이 없다. + whereOptions.status = { equals: ProblemStatus.Published } + if (input.difficulty) { whereOptions.difficulty = { in: input.difficulty @@ -401,7 +405,9 @@ export class ProblemService { async getProblem(id: number, userRole: Role, userId: number) { const problem = await this.prisma.problem.findFirstOrThrow({ where: { - id + id, + // 만들당 Draft/Ready 문제는 전용 화면에서만 조회한다 (getProblems와 동일한 정책). + status: ProblemStatus.Published }, include: { sharedGroups: true @@ -736,7 +742,8 @@ export class ProblemService { } // Problem description에 이미지가 포함되어 있다면 삭제 - const uuidImageFileNames = this.extractUUIDs(problem.description) + // 만들당 Draft 문제는 description이 아직 없을 수 있다 (nullable) — 그 경우 추출할 이미지가 없다. + const uuidImageFileNames = this.extractUUIDs(problem.description ?? '') if (uuidImageFileNames) { await this.prisma.file.deleteMany({ where: { diff --git a/apps/backend/apps/admin/src/submission/submission.service.ts b/apps/backend/apps/admin/src/submission/submission.service.ts index adf7be180d..73b6090e10 100644 --- a/apps/backend/apps/admin/src/submission/submission.service.ts +++ b/apps/backend/apps/admin/src/submission/submission.service.ts @@ -19,6 +19,7 @@ import type { AuthenticatedUser } from '@libs/auth' import { EntityNotExistException, ForbiddenAccessException, + UnprocessableDataException, UnprocessableFileDataException } from '@libs/exception' import { PrismaService } from '@libs/prisma' @@ -958,7 +959,17 @@ export class SubmissionService { } } - return { assignment, problem } + // 만들당 Draft/Ready 문제는 timeLimit/memoryLimit이 아직 없을 수 있다(nullable). + // 재채점은 이 값을 Iris로 그대로 보내야 하므로, 없으면 여기서 명확히 막는다 — + // 원래는 발행 검증(publish validation)에서 걸러졌어야 할 상태다. + const { timeLimit, memoryLimit } = problem + if (timeLimit == null || memoryLimit == null) { + throw new UnprocessableDataException( + 'Problem is missing timeLimit/memoryLimit and cannot be rejudged' + ) + } + + return { assignment, problem: { ...problem, timeLimit, memoryLimit } } } /** diff --git a/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts b/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts index 889145bc04..4a4001505b 100644 --- a/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts +++ b/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts @@ -11,20 +11,23 @@ import { Exclude, Expose } from 'class-transformer' export class ProblemResponseDto { id: number title: string - description: string - inputDescription: string - outputDescription: string - hint: string + // 만들당 Draft/Ready 문제는 이 필드들이 아직 채워지지 않을 수 있다(nullable). + // 이 DTO는 status=Published 문제만 반환하는 getProblem()에서 쓰이므로, 정상적으로는 + // 항상 값이 채워져 있어야 한다 — 다만 스키마 자체는 nullable이라 타입도 그에 맞춘다. + description: string | null + inputDescription: string | null + outputDescription: string | null + hint: string | null engTitle: string | null engDescription: string | null engInputDescription: string | null engOutputDescription: string | null engHint: string | null languages: Language[] - timeLimit: number - memoryLimit: number - difficulty: Level - source: string + timeLimit: number | null + memoryLimit: number | null + difficulty: Level | null + source: string | null submissionCount: number acceptedCount: number acceptedRate: number diff --git a/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts b/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts index 4af944a966..dc097d594e 100644 --- a/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts +++ b/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts @@ -10,7 +10,7 @@ class Problem { id: number title: string engTitle: string | null - difficulty: Level + difficulty: Level | null submissionCount: number acceptedRate: number tags: Partial[] diff --git a/apps/backend/apps/client/src/problem/problem.service.ts b/apps/backend/apps/client/src/problem/problem.service.ts index 354514ed07..2fda940833 100644 --- a/apps/backend/apps/client/src/problem/problem.service.ts +++ b/apps/backend/apps/client/src/problem/problem.service.ts @@ -1,5 +1,5 @@ import { ForbiddenException, Injectable } from '@nestjs/common' -import { Prisma, ResultStatus } from '@prisma/client' +import { Prisma, ProblemStatus, ResultStatus } from '@prisma/client' import type { Decimal } from '@prisma/client/runtime/library' import { MIN_DATE } from '@libs/constants' import { ForbiddenAccessException } from '@libs/exception' @@ -108,7 +108,10 @@ export class ProblemService { // 아니면 텍스트가 많은 field에서는 full-text search를 사용하고, 텍스트가 적은 field에서는 contains를 사용하는 방법도 고려해보자. contains: search }, - visibleLockTime: MIN_DATE + visibleLockTime: MIN_DATE, + // 만들당 Draft/Ready 문제는 학생에게 아직 공개되면 안 된다. + // 레거시 문제는 status가 항상 Published(스키마 기본값)라 영향 없다. + status: ProblemStatus.Published }, select: { ...problemsSelectOption, @@ -215,7 +218,9 @@ export class ProblemService { const data = await this.prisma.problem.findUniqueOrThrow({ where: { id: problemId, - visibleLockTime: MIN_DATE + visibleLockTime: MIN_DATE, + // 만들당 Draft/Ready 문제는 학생에게 아직 공개되면 안 된다. + status: ProblemStatus.Published }, select: problemSelectOption }) diff --git a/apps/backend/apps/client/src/submission/submission-pub.service.ts b/apps/backend/apps/client/src/submission/submission-pub.service.ts index fb756aad3b..6f8e20a394 100644 --- a/apps/backend/apps/client/src/submission/submission-pub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-pub.service.ts @@ -2,7 +2,10 @@ import { Injectable } from '@nestjs/common' import type { Submission, TestSubmission } from '@prisma/client' import { Span } from 'nestjs-otel' import { JudgeAMQPService } from '@libs/amqp' -import { EntityNotExistException } from '@libs/exception' +import { + EntityNotExistException, + UnprocessableDataException +} from '@libs/exception' import { PrismaService } from '@libs/prisma' import { Snippet } from './class/create-submission.dto' import { JudgeRequest, UserTestcaseJudgeRequest } from './class/judge-request' @@ -69,18 +72,29 @@ export class SubmissionPublicationService { throw new EntityNotExistException('Problem') } + // 만들당 Draft/Ready 문제는 timeLimit/memoryLimit이 아직 없을 수 있다(nullable) — + // 채점 요청을 만들려면 이 값이 반드시 있어야 하므로 여기서 명확히 막는다. + // 원래는 발행 검증(publish validation)에서 걸러졌어야 할 상태다. + const { timeLimit, memoryLimit } = problem + if (timeLimit == null || memoryLimit == null) { + throw new UnprocessableDataException( + 'Problem is missing timeLimit/memoryLimit and cannot be judged' + ) + } + const judgeableProblem = { ...problem, timeLimit, memoryLimit } + const judgeRequest = isUserTest ? new UserTestcaseJudgeRequest( code, submission.language, - problem, + judgeableProblem, userTestcases!, stopOnNotAccepted ) : new JudgeRequest( code, submission.language, - problem, + judgeableProblem, stopOnNotAccepted, judgeOnlyHiddenTestcases, containHiddenTestcases diff --git a/apps/backend/apps/client/src/submission/submission.service.ts b/apps/backend/apps/client/src/submission/submission.service.ts index e47e7a12dd..f6a84ea8b0 100644 --- a/apps/backend/apps/client/src/submission/submission.service.ts +++ b/apps/backend/apps/client/src/submission/submission.service.ts @@ -7,6 +7,7 @@ import { Language, Prisma, Problem, + ProblemStatus, ResultStatus, Role, Submission, @@ -86,7 +87,10 @@ export class SubmissionService { id: problemId, visibleLockTime: { equals: MIN_DATE - } + }, + // 만들당 Draft/Ready 문제는 발행 전이라 제출을 받으면 안 된다. + // 레거시 문제는 status가 항상 Published(스키마 기본값)라 영향 없다. + status: ProblemStatus.Published } }) if (!problem) { @@ -206,6 +210,10 @@ export class SubmissionService { throw new EntityNotExistException('ContestProblem') } const { problem } = contestProblem + // 만들당 Draft/Ready 문제는 발행 전이라 제출을 받으면 안 된다. + if (problem.status !== ProblemStatus.Published) { + throw new EntityNotExistException('Problem') + } const submission = await this.createSubmission({ submissionDto, @@ -332,6 +340,10 @@ export class SubmissionService { throw new EntityNotExistException('AssignmentProblem') } const { problem } = assignmentProblem + // 만들당 Draft/Ready 문제는 발행 전이라 제출을 받으면 안 된다. + if (problem.status !== ProblemStatus.Published) { + throw new EntityNotExistException('Problem') + } await this.prisma.assignmentProblemRecord.upsert({ where: { @@ -415,7 +427,11 @@ export class SubmissionService { throw new EntityNotExistException('WorkbookProblem') } const { problem } = workbookProblem - if (problem.visibleLockTime.getTime() !== MIN_DATE.getTime()) { + // 만들당 Draft/Ready 문제는 발행 전이라 제출을 받으면 안 된다. + if ( + problem.visibleLockTime.getTime() !== MIN_DATE.getTime() || + problem.status !== ProblemStatus.Published + ) { throw new EntityNotExistException('Problem') } diff --git a/apps/backend/apps/client/src/submission/test/submission-pub.service.spec.ts b/apps/backend/apps/client/src/submission/test/submission-pub.service.spec.ts index efba19a0f2..717bfa5caa 100644 --- a/apps/backend/apps/client/src/submission/test/submission-pub.service.spec.ts +++ b/apps/backend/apps/client/src/submission/test/submission-pub.service.spec.ts @@ -27,6 +27,15 @@ const submission: Submission & { submissionResult: SubmissionResult[] } = { score: new Prisma.Decimal(100) } +// problems[0]는 Problem 전체 타입이라 timeLimit/memoryLimit이 number | null이다 — +// 이 mock은 실제로 항상 값이 채워져 있으므로, JudgeRequest 생성자가 요구하는 +// non-null 타입에 맞춰 좁혀둔다. +const judgeableProblem = { + ...problems[0], + timeLimit: problems[0].timeLimit!, + memoryLimit: problems[0].memoryLimit! +} + describe('SubmissionPublicationService', () => { let service: SubmissionPublicationService let amqpService: JudgeAMQPService @@ -76,7 +85,7 @@ describe('SubmissionPublicationService', () => { const judgeRequest = new JudgeRequest( submissions[0].code, submission.language, - problems[0] + judgeableProblem ) await expect( @@ -112,7 +121,7 @@ describe('SubmissionPublicationService', () => { const judgeRequest = new JudgeRequest( submissions[0].code, submission.language, - problems[0] + judgeableProblem ) await expect( @@ -164,7 +173,7 @@ describe('SubmissionPublicationService', () => { const userTestcaseJudgeRequest = new UserTestcaseJudgeRequest( submissions[0].code, submission.language, - problems[0], + judgeableProblem, userTestcases, true ) @@ -211,7 +220,7 @@ describe('SubmissionPublicationService', () => { const judgeRequest = new JudgeRequest( submissions[0].code, submission.language, - problems[0], + judgeableProblem, true, true, true diff --git a/apps/backend/apps/client/src/submission/test/submission.service.spec.ts b/apps/backend/apps/client/src/submission/test/submission.service.spec.ts index 0e8be95993..a785f7bd6e 100644 --- a/apps/backend/apps/client/src/submission/test/submission.service.spec.ts +++ b/apps/backend/apps/client/src/submission/test/submission.service.spec.ts @@ -4,7 +4,7 @@ import { ConfigService } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import { Test } from '@nestjs/testing' import type { Contest, User, Assignment } from '@prisma/client' -import { Language, Role } from '@prisma/client' +import { Language, ProblemStatus, Role } from '@prisma/client' import type { Cache } from 'cache-manager' import { expect } from 'chai' import { plainToInstance } from 'class-transformer' @@ -244,6 +244,24 @@ describe('SubmissionService', () => { ).to.be.rejectedWith(EntityNotExistException) expect(createSpy.called).to.be.false }) + + it('should only look up Published problems (excludes Draft/Ready mandeuldang problems)', async () => { + db.problem.findFirst.resolves(problems[0]) + stub(service, 'createSubmission') + + await service.submitToProblem({ + submissionDto, + userIp: USERIP, + userId: submissions[0].userId, + problemId: problems[0].id + }) + + expect( + db.problem.findFirst.calledWithMatch({ + where: { status: ProblemStatus.Published } + }) + ).to.be.true + }) }) describe('submitToContest', () => { @@ -285,6 +303,33 @@ describe('SubmissionService', () => { ).to.be.rejectedWith(EntityNotExistException) expect(createSpy.called).to.be.false }) + + it('should throw exception if the problem is not yet published', async () => { + const createSpy = stub(service, 'createSubmission') + db.contest.findFirst.resolves(mockContest) + db.userContest.findFirst.resolves(null) + db.contestRecord.findUnique.resolves({ + contest: { + groupId: 1, + startTime: new Date(Date.now() - 10000), + endTime: new Date(Date.now() + 10000) + } + }) + db.contestProblem.findUnique.resolves({ + problem: { ...problems[0], status: ProblemStatus.Draft } + }) + + await expect( + service.submitToContest({ + submissionDto, + userIp: USERIP, + userId: submissions[0].userId, + problemId: problems[0].id, + contestId: CONTEST_ID + }) + ).to.be.rejectedWith(EntityNotExistException) + expect(createSpy.called).to.be.false + }) }) describe('submitToAssignment', () => { @@ -327,6 +372,34 @@ describe('SubmissionService', () => { ).to.be.rejectedWith(EntityNotExistException) expect(createSpy.called).to.be.false }) + + it('should throw exception if the problem is not yet published', async () => { + const createSpy = stub(service, 'createSubmission') + db.assignment.findFirst.resolves(mockAssignment) + db.assignmentRecord.findUnique.resolves({ + id: 1, + assignment: { + groupId: 1, + startTime: new Date(Date.now() - 10000), + endTime: new Date(Date.now() + 10000), + dueTime: new Date(Date.now() + 5000) + } + }) + db.assignmentProblem.findUnique.resolves({ + problem: { ...problems[0], status: ProblemStatus.Draft } + }) + + await expect( + service.submitToAssignment({ + submissionDto, + userIp: USERIP, + userId: submissions[0].userId, + problemId: problems[0].id, + assignmentId: ASSIGNMENT_ID + }) + ).to.be.rejectedWith(EntityNotExistException) + expect(createSpy.called).to.be.false + }) }) describe('submitToWorkbook', () => { @@ -359,6 +432,24 @@ describe('SubmissionService', () => { ).to.be.rejectedWith(EntityNotExistException) expect(createSpy.called).to.be.false }) + + it('should throw exception if the problem is not yet published', async () => { + const createSpy = stub(service, 'createSubmission') + db.workbookProblem.findUnique.resolves({ + problem: { ...problems[0], status: ProblemStatus.Draft } + }) + + await expect( + service.submitToWorkbook({ + submissionDto, + userIp: USERIP, + userId: submissions[0].userId, + problemId: problems[0].id, + workbookId: WORKBOOK_ID + }) + ).to.be.rejectedWith(EntityNotExistException) + expect(createSpy.called).to.be.false + }) }) describe('createSubmission', () => { From 41f72b139e87d047159b93a9c14994fc81a88008 Mon Sep 17 00:00:00 2001 From: han25-ya Date: Wed, 2 Sep 2026 23:58:42 +0900 Subject: [PATCH 8/8] fix(be): keep legacy Problem response contract non-null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 만들당 통합으로 Problem 필드가 nullable이 되면서 client 응답 DTO (ProblemResponseDto, ProblemsResponseDto)까지 nullable로 넓혀졌으나, 이는 기존 프론트엔드 계약을 깨뜨린다. status=Published 문제는 필수 필드가 항상 채워져 있으므로 DTO는 non-null로 되돌리고, 조회 경계에서 ensurePublishedProblemContent로 불변식을 검증한다 (Draft/Ready 만들당 문제는 만들당 전용 조회 API로 다룬다). 또한 각 호출부에 흩어진 status: Published 필터를 PUBLISHED_PROBLEM_WHERE 공통 상수로 모아 새 조회 경로에서 누락되지 않도록 한다. --- .../src/problem/services/problem.service.ts | 8 +- .../src/problem/dto/problem.response.dto.ts | 23 +++--- .../src/problem/dto/problems.response.dto.ts | 3 +- .../client/src/problem/problem.service.ts | 81 ++++++++++++++++--- apps/backend/libs/constants/src/index.ts | 1 + .../libs/constants/src/problem.constants.ts | 17 ++++ 6 files changed, 107 insertions(+), 26 deletions(-) create mode 100644 apps/backend/libs/constants/src/problem.constants.ts diff --git a/apps/backend/apps/admin/src/problem/services/problem.service.ts b/apps/backend/apps/admin/src/problem/services/problem.service.ts index a727725255..fc6ccd72f9 100644 --- a/apps/backend/apps/admin/src/problem/services/problem.service.ts +++ b/apps/backend/apps/admin/src/problem/services/problem.service.ts @@ -7,11 +7,11 @@ import { ProblemWhereInput, UpdateHistory } from '@generated' -import { ContestRole, ProblemField, ProblemStatus, Role } from '@prisma/client' +import { ContestRole, ProblemField, Role } from '@prisma/client' import { Workbook } from 'exceljs' import { Response } from 'express' import { Readable } from 'stream' -import { MAX_DATE, MIN_DATE } from '@libs/constants' +import { MAX_DATE, MIN_DATE, PUBLISHED_PROBLEM_WHERE } from '@libs/constants' import { EntityNotExistException, UnprocessableDataException, @@ -299,7 +299,7 @@ export class ProblemService { // 만들당 Draft/Ready 문제는 전용 화면(제작 중인 문제)에서만 다룬다 — 기존 문제 목록/조회에는 // 노출하지 않는다. 기존 Legacy 문제는 status가 항상 Published(스키마 기본값)라 영향이 없다. - whereOptions.status = { equals: ProblemStatus.Published } + Object.assign(whereOptions, PUBLISHED_PROBLEM_WHERE) if (input.difficulty) { whereOptions.difficulty = { @@ -407,7 +407,7 @@ export class ProblemService { where: { id, // 만들당 Draft/Ready 문제는 전용 화면에서만 조회한다 (getProblems와 동일한 정책). - status: ProblemStatus.Published + ...PUBLISHED_PROBLEM_WHERE }, include: { sharedGroups: true diff --git a/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts b/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts index 4a4001505b..41436910b5 100644 --- a/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts +++ b/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts @@ -11,23 +11,24 @@ import { Exclude, Expose } from 'class-transformer' export class ProblemResponseDto { id: number title: string - // 만들당 Draft/Ready 문제는 이 필드들이 아직 채워지지 않을 수 있다(nullable). - // 이 DTO는 status=Published 문제만 반환하는 getProblem()에서 쓰이므로, 정상적으로는 - // 항상 값이 채워져 있어야 한다 — 다만 스키마 자체는 nullable이라 타입도 그에 맞춘다. - description: string | null - inputDescription: string | null - outputDescription: string | null - hint: string | null + // 이 DTO는 status=Published 문제만 반환하는 getProblem()에서만 쓰인다. + // 스키마상 nullable이지만 발행된 문제라면 아래 필드가 항상 채워져 있으므로 + // (getProblem에서 assertPublishedProblemContent로 보장) 기존 non-null 계약을 유지한다. + // 발행 전(Draft/Ready) 만들당 문제는 이 DTO가 아니라 만들당 전용 조회 API로 다룬다. + description: string + inputDescription: string + outputDescription: string + hint: string engTitle: string | null engDescription: string | null engInputDescription: string | null engOutputDescription: string | null engHint: string | null languages: Language[] - timeLimit: number | null - memoryLimit: number | null - difficulty: Level | null - source: string | null + timeLimit: number + memoryLimit: number + difficulty: Level + source: string submissionCount: number acceptedCount: number acceptedRate: number diff --git a/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts b/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts index dc097d594e..68aa64346e 100644 --- a/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts +++ b/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts @@ -10,7 +10,8 @@ class Problem { id: number title: string engTitle: string | null - difficulty: Level | null + // 목록도 status=Published 문제만 반환하므로 기존 non-null 계약을 유지한다. + difficulty: Level submissionCount: number acceptedRate: number tags: Partial[] diff --git a/apps/backend/apps/client/src/problem/problem.service.ts b/apps/backend/apps/client/src/problem/problem.service.ts index 2fda940833..c39b5bfe8a 100644 --- a/apps/backend/apps/client/src/problem/problem.service.ts +++ b/apps/backend/apps/client/src/problem/problem.service.ts @@ -1,8 +1,11 @@ import { ForbiddenException, Injectable } from '@nestjs/common' -import { Prisma, ProblemStatus, ResultStatus } from '@prisma/client' +import { Prisma, ResultStatus, type Level } from '@prisma/client' import type { Decimal } from '@prisma/client/runtime/library' -import { MIN_DATE } from '@libs/constants' -import { ForbiddenAccessException } from '@libs/exception' +import { MIN_DATE, PUBLISHED_PROBLEM_WHERE } from '@libs/constants' +import { + ForbiddenAccessException, + UnprocessableDataException +} from '@libs/exception' import { ProblemOrder } from '@libs/pipe' import { PrismaService } from '@libs/prisma' import { AssignmentService } from '@client/assignment/assignment.service' @@ -50,6 +53,53 @@ const problemSelectOption: Prisma.ProblemSelect = { } } +type PublishedProblemContent = { + description: string + inputDescription: string + outputDescription: string + hint: string + timeLimit: number + memoryLimit: number + difficulty: Level + source: string +} + +type NullableProblemContent = { + [K in keyof PublishedProblemContent]: PublishedProblemContent[K] | null +} + +/** + * status=Published 문제가 응답에 필요한 필수 콘텐츠 필드를 모두 갖췄는지 검증하고, + * 해당 필드들이 non-null로 좁혀진 동일 객체를 반환합니다. + * + * 스키마상 nullable이지만 발행된 문제라면 항상 채워져 있어야 하며(레거시 문제는 생성 + * 시점에, 만들당 문제는 발행 검증에서 보장), 비어 있다면 발행 검증(publish validation) + * 쪽 버그이므로 nullable 값을 응답 계약에 흘리지 않고 예외를 던집니다. + * + * @throws {UnprocessableDataException} 필수 필드가 비어 있는 경우 + */ +const ensurePublishedProblemContent = ( + problem: T +): T & PublishedProblemContent => { + const requiredFields = [ + 'description', + 'inputDescription', + 'outputDescription', + 'hint', + 'timeLimit', + 'memoryLimit', + 'difficulty', + 'source' + ] as const + const missingFields = requiredFields.filter((field) => problem[field] == null) + if (missingFields.length > 0) { + throw new UnprocessableDataException( + `Published problem is missing required fields: ${missingFields.join(', ')}` + ) + } + return problem as T & PublishedProblemContent +} + @Injectable() export class ProblemService { constructor(private readonly prisma: PrismaService) {} @@ -109,9 +159,8 @@ export class ProblemService { contains: search }, visibleLockTime: MIN_DATE, - // 만들당 Draft/Ready 문제는 학생에게 아직 공개되면 안 된다. - // 레거시 문제는 status가 항상 Published(스키마 기본값)라 영향 없다. - status: ProblemStatus.Published + // 발행 전(Draft/Ready) 만들당 문제는 목록에 노출하지 않는다. + ...PUBLISHED_PROBLEM_WHERE }, select: { ...problemsSelectOption, @@ -152,6 +201,13 @@ export class ProblemService { submissionCount, title } = problem + // 목록은 status=Published 문제만 조회하므로 difficulty가 항상 존재해야 한다. + // 비어 있으면 발행 검증 쪽 버그이므로 nullable 값을 응답에 흘리지 않고 막는다. + if (difficulty == null) { + throw new UnprocessableDataException( + `Published problem ${id} is missing required field: difficulty` + ) + } let hasPassed: boolean | null = null const problemTags = problemTag.map((tag) => tag.tagId) const tags = tagList.filter((tagItem) => problemTags.includes(tagItem.id)) @@ -219,12 +275,14 @@ export class ProblemService { where: { id: problemId, visibleLockTime: MIN_DATE, - // 만들당 Draft/Ready 문제는 학생에게 아직 공개되면 안 된다. - status: ProblemStatus.Published + // 발행 전(Draft/Ready) 만들당 문제는 학생에게 공개되면 안 된다. + ...PUBLISHED_PROBLEM_WHERE }, select: problemSelectOption }) + const problem = ensurePublishedProblemContent(data) + const tags = ( await this.prisma.problemTag.findMany({ where: { @@ -244,7 +302,7 @@ export class ProblemService { const updateHistory = await this.getProblemUpdateHistory(problemId) return { - ...data, + ...problem, tags, updateHistory } @@ -1024,6 +1082,9 @@ export class WorkbookProblemService { } }) + // 워크북 문제 상세도 발행된(Published) 문제만 노출한다 — 필수 콘텐츠 필드가 채워져 있어야 한다. + const publishedProblem = ensurePublishedProblemContent(data.problem) + const tags = ( await this.prisma.problemTag.findMany({ where: { @@ -1050,7 +1111,7 @@ export class WorkbookProblemService { 'visibleLockTime', 'solution' ] - const problem = { ...data.problem } + const problem = { ...publishedProblem } excludedFields.forEach((key) => { delete problem[key] }) diff --git a/apps/backend/libs/constants/src/index.ts b/apps/backend/libs/constants/src/index.ts index 825dbb8be4..90125da600 100644 --- a/apps/backend/libs/constants/src/index.ts +++ b/apps/backend/libs/constants/src/index.ts @@ -2,6 +2,7 @@ export * from './oauth.constants' export * from './time.constants' export * from './rabbitmq.constants' export * from './check.constants' +export * from './problem.constants' export * from './submission.constants' export * from './argon2.constants' export * from './score-calculation.constants' diff --git a/apps/backend/libs/constants/src/problem.constants.ts b/apps/backend/libs/constants/src/problem.constants.ts new file mode 100644 index 0000000000..5c4e9b7372 --- /dev/null +++ b/apps/backend/libs/constants/src/problem.constants.ts @@ -0,0 +1,17 @@ +import { ProblemStatus } from '@prisma/client' + +/** + * 만들당(Mandeuldang) Draft/Ready 문제를 기존(레거시) 조회·제출 경로에서 제외하기 위한 + * 공통 where 조각. + * + * 만들당 문제 제작은 기존 `Problem` 모델을 그대로 쓰되 `status`(Draft/Ready/Published)로 + * 발행 상태를 관리한다. 발행 전(Draft/Ready) 문제는 `timeLimit` 등 필수 필드가 비어 있을 수 + * 있으므로, 학생/일반 조회나 제출 경로에 노출되면 안 된다. 각 호출부에서 개별적으로 + * `status: Published`를 붙이면 새 조회 경로가 추가될 때 누락되기 쉬우므로 한 곳에서 관리한다. + * + * 만들당 제작 전용 경로(management > problem > 제작 중인 문제)는 이 필터를 쓰지 않고 + * 전용 resolver/service를 통해 Draft/Ready 문제를 조회한다. + */ +export const PUBLISHED_PROBLEM_WHERE = { + status: ProblemStatus.Published +} as const