From eb8aef8e67c58ffdc8e0350d4f77daf3c71050d0 Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Wed, 11 Feb 2026 07:09:31 +0000 Subject: [PATCH 01/17] feat(be): add study group features with total study time and study info table --- .../migration.sql | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 apps/backend/prisma/migrations/20260211055534_add_study_group_features/migration.sql diff --git a/apps/backend/prisma/migrations/20260211055534_add_study_group_features/migration.sql b/apps/backend/prisma/migrations/20260211055534_add_study_group_features/migration.sql new file mode 100644 index 0000000000..827a863cfc --- /dev/null +++ b/apps/backend/prisma/migrations/20260211055534_add_study_group_features/migration.sql @@ -0,0 +1,18 @@ +-- AlterTable +ALTER TABLE "user_group" ADD COLUMN "total_study_time" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable +CREATE TABLE "study_info" ( + "id" SERIAL NOT NULL, + "group_id" INTEGER NOT NULL, + "capacity" INTEGER NOT NULL DEFAULT 10, + "invitation_code" TEXT, + + CONSTRAINT "study_info_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "study_info_group_id_key" ON "study_info"("group_id"); + +-- AddForeignKey +ALTER TABLE "study_info" ADD CONSTRAINT "study_info_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE CASCADE ON UPDATE CASCADE; From 59dde766847a1ecf1be1185668380295783159e3 Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Wed, 11 Feb 2026 07:38:44 +0000 Subject: [PATCH 02/17] feat(be): add totalStudyTime to UserGroup and create StudyInfo model for group study settings --- apps/backend/prisma/schema.prisma | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index c2fe9266e8..449ecc080b 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -121,6 +121,8 @@ model UserGroup { createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") + totalStudyTime Int? @default(0) @map("total_study_time") + @@id([userId, groupId]) @@map("user_group") } @@ -155,6 +157,8 @@ model Group { courseQnA CourseQnA[] CourseNotice CourseNotice[] + studyInfo StudyInfo? + @@map("group") } @@ -1013,3 +1017,17 @@ model CourseQnAComment { @@unique([courseQnAId, order]) @@map("course_qna_comment") } + +model StudyInfo { + id Int @id @default(autoincrement()) + groupId Int @unique @map("group_id") + + // 스터디 설정 + capacity Int @default(10) // 최대 인원 (1~10명) + invitationCode String? @map("invitation_code") // 비공개 시 입장 코드 (6자리) + + // 관계 설정 (Group 삭제 시 StudyInfo도 자동 삭제) + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + + @@map("study_info") +} From 7e36ca4e713b5d4e21b246e40979f441b2bbedf0 Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Wed, 11 Feb 2026 07:51:24 +0000 Subject: [PATCH 03/17] feat(be): add capacity and invitationCode fields to CreateGroupInput; update group output types --- apps/backend/apps/admin/src/group/model/group.input.ts | 6 ++++++ apps/backend/apps/admin/src/group/model/group.output.ts | 8 ++++---- .../20260211074552_make_study_time_optional/migration.sql | 2 ++ 3 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260211074552_make_study_time_optional/migration.sql diff --git a/apps/backend/apps/admin/src/group/model/group.input.ts b/apps/backend/apps/admin/src/group/model/group.input.ts index 26b966342a..82efbb3791 100644 --- a/apps/backend/apps/admin/src/group/model/group.input.ts +++ b/apps/backend/apps/admin/src/group/model/group.input.ts @@ -21,6 +21,12 @@ class Config { export class CreateGroupInput extends GroupCreateInput { @Field(() => Config, { nullable: false }) declare config: Config + + @Field(() => Int, { nullable: true, description: 'Study Group 정원 (1~10)' }) + capacity?: number + + @Field({ nullable: true, description: 'Study Group 비공개 초대 코드' }) + invitationCode?: string } @InputType() diff --git a/apps/backend/apps/admin/src/group/model/group.output.ts b/apps/backend/apps/admin/src/group/model/group.output.ts index 05e5a86b3f..f6387dc2ca 100644 --- a/apps/backend/apps/admin/src/group/model/group.output.ts +++ b/apps/backend/apps/admin/src/group/model/group.output.ts @@ -1,5 +1,5 @@ -import { Field, Int } from '@nestjs/graphql' -import { ObjectType } from '@nestjs/graphql' +import { Field, Int, ObjectType } from '@nestjs/graphql' +// @generated에서 Group과 StudyInfo를 모두 가져옵니다. import { Group } from '@generated' @ObjectType() @@ -17,10 +17,10 @@ export class DuplicateCourse { duplicatedCourse!: Group @Field(() => [Int], { nullable: false }) - originAssignments!: number + originAssignments!: number[] @Field(() => [Int], { nullable: false }) - copiedAssignments!: number + copiedAssignments!: number[] } @ObjectType() diff --git a/apps/backend/prisma/migrations/20260211074552_make_study_time_optional/migration.sql b/apps/backend/prisma/migrations/20260211074552_make_study_time_optional/migration.sql new file mode 100644 index 0000000000..631e45eab6 --- /dev/null +++ b/apps/backend/prisma/migrations/20260211074552_make_study_time_optional/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "user_group" ALTER COLUMN "total_study_time" DROP NOT NULL; From 98b9947cf69a99ebbab0c497683ff9d1b0845af1 Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Fri, 13 Feb 2026 13:00:26 +0000 Subject: [PATCH 04/17] feat(be): implement createGroup method in GroupService and update CreateGroupInput structure --- .../apps/admin/src/group/group.service.ts | 43 +++++++++++++++++++ .../apps/admin/src/group/model/group.input.ts | 18 +++++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/apps/backend/apps/admin/src/group/group.service.ts b/apps/backend/apps/admin/src/group/group.service.ts index a012b9a029..149700858b 100644 --- a/apps/backend/apps/admin/src/group/group.service.ts +++ b/apps/backend/apps/admin/src/group/group.service.ts @@ -18,6 +18,7 @@ import type { } from './model/course-notice.input' import type { UpdateCourseQnAInput } from './model/course-qna.input' import type { CourseInput } from './model/group.input' +import { CreateGroupInput } from './model/group.input' @Injectable() export class GroupService { @@ -90,6 +91,48 @@ export class GroupService { } } + async createGroup(input: CreateGroupInput, userId: number) { + const { groupName, description, isPrivate, capacity, invitationCode } = + input + + const existingGroup = await this.prisma.group.findFirst({ + where: { groupName } + }) + if (existingGroup) { + throw new ConflictFoundException('Group name already exists') + } + + const group = await this.prisma.group.create({ + data: { + groupName, + description: description ?? '', + config: { + type: 'Study', + isPrivate, + showOnList: true, + allowJoinFromSearch: true, + allowJoinWithURL: true, + requireApprovalBeforeJoin: false + }, + userGroup: { + create: { + userId, + isGroupLeader: true, + totalStudyTime: 0 + } + }, + studyInfo: { + create: { + capacity: capacity ?? 10, + invitationCode: isPrivate ? invitationCode : null + } + } + } + }) + + return group + } + async getCourses(cursor: number | null, take: number) { const paginator = this.prisma.getPaginator(cursor) diff --git a/apps/backend/apps/admin/src/group/model/group.input.ts b/apps/backend/apps/admin/src/group/model/group.input.ts index 82efbb3791..8357657f48 100644 --- a/apps/backend/apps/admin/src/group/model/group.input.ts +++ b/apps/backend/apps/admin/src/group/model/group.input.ts @@ -1,6 +1,6 @@ import { Field, Int } from '@nestjs/graphql' import { InputType } from '@nestjs/graphql' -import { GroupCreateInput, GroupUpdateInput } from '@generated' +import { GroupUpdateInput } from '@generated' @InputType() class Config { @@ -18,14 +18,20 @@ class Config { } @InputType() -export class CreateGroupInput extends GroupCreateInput { - @Field(() => Config, { nullable: false }) - declare config: Config +export class CreateGroupInput { + @Field(() => String) + groupName: string + + @Field(() => String, { nullable: true }) + description?: string + + @Field(() => Boolean, { defaultValue: false }) + isPrivate: boolean - @Field(() => Int, { nullable: true, description: 'Study Group 정원 (1~10)' }) + @Field(() => Int, { nullable: true, description: '최대 인원 (기본값: 10)' }) capacity?: number - @Field({ nullable: true, description: 'Study Group 비공개 초대 코드' }) + @Field(() => String, { nullable: true, description: '비공개 초대 코드' }) invitationCode?: string } From 6575cfe895880cc36358c6f5f8b1f98664388c87 Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Fri, 13 Feb 2026 13:19:18 +0000 Subject: [PATCH 05/17] feat(be): add createGroup mutation and update group input/output models --- .vscode/settings.json | 3 ++- apps/backend/apps/admin/src/group/group.resolver.ts | 11 ++++++++++- apps/backend/apps/admin/src/group/group.service.ts | 3 +-- .../backend/apps/admin/src/group/model/group.input.ts | 7 +++++-- .../apps/admin/src/group/model/group.output.ts | 1 - apps/backend/prisma/schema.prisma | 6 ++---- 6 files changed, 20 insertions(+), 11 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 99fea9b63a..fe6d4b7bf0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,5 +16,6 @@ }, "[caddyfile]": { "editor.defaultFormatter": "matthewpi.caddyfile-support" - } + }, + "prisma.pinToPrisma6": true } diff --git a/apps/backend/apps/admin/src/group/group.resolver.ts b/apps/backend/apps/admin/src/group/group.resolver.ts index 64a19baeba..287c585e06 100644 --- a/apps/backend/apps/admin/src/group/group.resolver.ts +++ b/apps/backend/apps/admin/src/group/group.resolver.ts @@ -25,7 +25,7 @@ import { UpdateCourseNoticeInput } from './model/course-notice.input' import { UpdateCourseQnAInput } from './model/course-qna.input' -import { CourseInput } from './model/group.input' +import { CourseInput, CreateGroupInput } from './model/group.input' import { DuplicateCourse, FindGroup } from './model/group.output' @Resolver(() => Group) @@ -68,6 +68,15 @@ export class GroupResolver { return await this.groupService.duplicateCourse(groupId, req.user.id) } + @Mutation(() => Group) + @UseGroupLeaderGuard() + async createGroup( + @Args('input') input: CreateGroupInput, + @Context('req') req: AuthenticatedRequest + ) { + return await this.groupService.createGroup(input, req.user.id) + } + @Query(() => [FindGroup]) @UseDisableAdminGuard() async getCoursesUserLead(@Context('req') req: AuthenticatedRequest) { diff --git a/apps/backend/apps/admin/src/group/group.service.ts b/apps/backend/apps/admin/src/group/group.service.ts index 149700858b..363a4f0d6f 100644 --- a/apps/backend/apps/admin/src/group/group.service.ts +++ b/apps/backend/apps/admin/src/group/group.service.ts @@ -117,8 +117,7 @@ export class GroupService { userGroup: { create: { userId, - isGroupLeader: true, - totalStudyTime: 0 + isGroupLeader: true } }, studyInfo: { diff --git a/apps/backend/apps/admin/src/group/model/group.input.ts b/apps/backend/apps/admin/src/group/model/group.input.ts index 8357657f48..1db1a2debd 100644 --- a/apps/backend/apps/admin/src/group/model/group.input.ts +++ b/apps/backend/apps/admin/src/group/model/group.input.ts @@ -28,10 +28,13 @@ export class CreateGroupInput { @Field(() => Boolean, { defaultValue: false }) isPrivate: boolean - @Field(() => Int, { nullable: true, description: '최대 인원 (기본값: 10)' }) + @Field(() => Int, { nullable: true, description: 'Max people (default: 10)' }) capacity?: number - @Field(() => String, { nullable: true, description: '비공개 초대 코드' }) + @Field(() => String, { + nullable: true, + description: 'Private invitation code' + }) invitationCode?: string } diff --git a/apps/backend/apps/admin/src/group/model/group.output.ts b/apps/backend/apps/admin/src/group/model/group.output.ts index f6387dc2ca..bd1a2f350c 100644 --- a/apps/backend/apps/admin/src/group/model/group.output.ts +++ b/apps/backend/apps/admin/src/group/model/group.output.ts @@ -1,5 +1,4 @@ import { Field, Int, ObjectType } from '@nestjs/graphql' -// @generated에서 Group과 StudyInfo를 모두 가져옵니다. import { Group } from '@generated' @ObjectType() diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 449ecc080b..309fd66ee6 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1022,11 +1022,9 @@ model StudyInfo { id Int @id @default(autoincrement()) groupId Int @unique @map("group_id") - // 스터디 설정 - capacity Int @default(10) // 최대 인원 (1~10명) - invitationCode String? @map("invitation_code") // 비공개 시 입장 코드 (6자리) + capacity Int @default(10) + invitationCode String? @map("invitation_code") - // 관계 설정 (Group 삭제 시 StudyInfo도 자동 삭제) group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) @@map("study_info") From d87951d66f81715cc438b2eff57d9842675f8547 Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Fri, 13 Feb 2026 14:20:59 +0000 Subject: [PATCH 06/17] feat(be): add totalStudyTime to UserGroup and create StudyInfo model for study group features --- .../admin/src/group/group.service.spec.ts | 25 +++++++++++++++++-- .../migration.sql | 18 ------------- .../migration.sql | 2 -- .../migration.sql | 14 +++++++++++ apps/backend/prisma/schema.prisma | 4 +-- 5 files changed, 38 insertions(+), 25 deletions(-) delete mode 100644 apps/backend/prisma/migrations/20260211055534_add_study_group_features/migration.sql delete mode 100644 apps/backend/prisma/migrations/20260211074552_make_study_time_optional/migration.sql create mode 100644 apps/backend/prisma/migrations/20260213141806_add_study_group_features/migration.sql diff --git a/apps/backend/apps/admin/src/group/group.service.spec.ts b/apps/backend/apps/admin/src/group/group.service.spec.ts index bfc2bcedee..cb87127200 100644 --- a/apps/backend/apps/admin/src/group/group.service.spec.ts +++ b/apps/backend/apps/admin/src/group/group.service.spec.ts @@ -66,14 +66,16 @@ const group = { groupId: 1, isGroupLeader: true, createTime: faker.date.past(), - updateTime: faker.date.past() + updateTime: faker.date.past(), + totalStudyTime: 0 }, { userId: faker.number.int(), groupId: 1, isGroupLeader: false, createTime: faker.date.past(), - updateTime: faker.date.past() + updateTime: faker.date.past(), + totalStudyTime: 0 } ], groupName: 'Programming Basics', @@ -627,4 +629,23 @@ describe('WhitelistService', () => { ).to.be.true }) }) + + describe('Study Tracking (totalStudyTime)', () => { + it('Default totalStudyTime of User in new study group must be 0.', async () => { + const mockUserGroup = { + userId, + groupId, + isGroupLeader: false, + totalStudyTime: 0 + } + + db.userGroup.create.resolves(mockUserGroup) + + const result = await db.userGroup.create({ + data: { userId, groupId } + }) + + expect(result.totalStudyTime).to.equal(0) + }) + }) }) diff --git a/apps/backend/prisma/migrations/20260211055534_add_study_group_features/migration.sql b/apps/backend/prisma/migrations/20260211055534_add_study_group_features/migration.sql deleted file mode 100644 index 827a863cfc..0000000000 --- a/apps/backend/prisma/migrations/20260211055534_add_study_group_features/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ --- AlterTable -ALTER TABLE "user_group" ADD COLUMN "total_study_time" INTEGER NOT NULL DEFAULT 0; - --- CreateTable -CREATE TABLE "study_info" ( - "id" SERIAL NOT NULL, - "group_id" INTEGER NOT NULL, - "capacity" INTEGER NOT NULL DEFAULT 10, - "invitation_code" TEXT, - - CONSTRAINT "study_info_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "study_info_group_id_key" ON "study_info"("group_id"); - --- AddForeignKey -ALTER TABLE "study_info" ADD CONSTRAINT "study_info_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260211074552_make_study_time_optional/migration.sql b/apps/backend/prisma/migrations/20260211074552_make_study_time_optional/migration.sql deleted file mode 100644 index 631e45eab6..0000000000 --- a/apps/backend/prisma/migrations/20260211074552_make_study_time_optional/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "user_group" ALTER COLUMN "total_study_time" DROP NOT NULL; diff --git a/apps/backend/prisma/migrations/20260213141806_add_study_group_features/migration.sql b/apps/backend/prisma/migrations/20260213141806_add_study_group_features/migration.sql new file mode 100644 index 0000000000..d2af20c449 --- /dev/null +++ b/apps/backend/prisma/migrations/20260213141806_add_study_group_features/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "public"."user_group" ADD COLUMN "total_study_time" INTEGER DEFAULT 0; + +-- CreateTable +CREATE TABLE "public"."study_info" ( + "group_id" INTEGER NOT NULL, + "capacity" INTEGER NOT NULL DEFAULT 10, + "invitation_code" TEXT, + + CONSTRAINT "study_info_pkey" PRIMARY KEY ("group_id") +); + +-- AddForeignKey +ALTER TABLE "public"."study_info" ADD CONSTRAINT "study_info_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."group"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 309fd66ee6..0bdec2be4c 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1019,9 +1019,7 @@ model CourseQnAComment { } model StudyInfo { - id Int @id @default(autoincrement()) - groupId Int @unique @map("group_id") - + groupId Int @id @map("group_id") capacity Int @default(10) invitationCode String? @map("invitation_code") From 9048d81bb0c59f9eabbf97ef8f689190e71644a7 Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Fri, 13 Feb 2026 14:41:20 +0000 Subject: [PATCH 07/17] feat(be): add totalStudyTime property to UserGroup in mock data and tests --- .../apps/admin/src/user/user.resolver.spec.ts | 3 ++- .../backend/apps/client/src/group/mock/group.mock.ts | 12 ++++++++---- .../libs/auth/src/roles/roles.service.spec.ts | 3 ++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/backend/apps/admin/src/user/user.resolver.spec.ts b/apps/backend/apps/admin/src/user/user.resolver.spec.ts index 3373a3908b..0c24c09684 100644 --- a/apps/backend/apps/admin/src/user/user.resolver.spec.ts +++ b/apps/backend/apps/admin/src/user/user.resolver.spec.ts @@ -92,7 +92,8 @@ describe('GroupMemberResolver', () => { groupId, isGroupLeader: false, createTime: faker.date.past(), - updateTime: faker.date.past() + updateTime: faker.date.past(), + totalStudyTime: 0 } // Mock the service method diff --git a/apps/backend/apps/client/src/group/mock/group.mock.ts b/apps/backend/apps/client/src/group/mock/group.mock.ts index fb1cca8794..f0164abee3 100644 --- a/apps/backend/apps/client/src/group/mock/group.mock.ts +++ b/apps/backend/apps/client/src/group/mock/group.mock.ts @@ -114,28 +114,32 @@ export const userGroups: UserGroup[] = [ userId: 1, createTime: new Date('2023-02-22T00:00:00.000Z'), updateTime: new Date('2023-02-22T10:00:00.000Z'), - isGroupLeader: true + isGroupLeader: true, + totalStudyTime: 0 }, { groupId: 1, userId: 2, createTime: new Date('2023-02-22T00:00:00.000Z'), updateTime: new Date('2023-02-22T10:00:00.000Z'), - isGroupLeader: false + isGroupLeader: false, + totalStudyTime: 0 }, { groupId: 2, userId: 1, createTime: new Date('2023-02-22T00:00:00.000Z'), updateTime: new Date('2023-02-22T10:00:00.000Z'), - isGroupLeader: true + isGroupLeader: true, + totalStudyTime: 0 }, { groupId: 2, userId: 2, createTime: new Date('2023-02-22T00:00:00.000Z'), updateTime: new Date('2023-02-22T10:00:00.000Z'), - isGroupLeader: false + isGroupLeader: false, + totalStudyTime: 0 } ] diff --git a/apps/backend/libs/auth/src/roles/roles.service.spec.ts b/apps/backend/libs/auth/src/roles/roles.service.spec.ts index fdefd154c8..835c88f236 100644 --- a/apps/backend/libs/auth/src/roles/roles.service.spec.ts +++ b/apps/backend/libs/auth/src/roles/roles.service.spec.ts @@ -28,7 +28,8 @@ const userGroup: UserGroup = { userId: 1, createTime: faker.date.past(), updateTime: faker.date.past(), - isGroupLeader: true + isGroupLeader: true, + totalStudyTime: 0 } const db = { From cb01f0aef2b3d1b7f0d4d3b04cf318f2f3506be5 Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Fri, 13 Feb 2026 14:42:43 +0000 Subject: [PATCH 08/17] feat(be): add totalStudyTime to user groups in test data --- apps/backend/apps/admin/src/user/user.service.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/backend/apps/admin/src/user/user.service.spec.ts b/apps/backend/apps/admin/src/user/user.service.spec.ts index cc844b033e..1c484413d9 100644 --- a/apps/backend/apps/admin/src/user/user.service.spec.ts +++ b/apps/backend/apps/admin/src/user/user.service.spec.ts @@ -69,7 +69,8 @@ const userGroup1: UserGroup = { groupId: 2, isGroupLeader: true, createTime: faker.date.past(), - updateTime: faker.date.past() + updateTime: faker.date.past(), + totalStudyTime: 0 } const userGroup2: UserGroup = { @@ -77,7 +78,8 @@ const userGroup2: UserGroup = { groupId: 2, isGroupLeader: true, createTime: faker.date.past(), - updateTime: faker.date.past() + updateTime: faker.date.past(), + totalStudyTime: 0 } const userGroup3: UserGroup = { @@ -85,7 +87,8 @@ const userGroup3: UserGroup = { groupId: 2, isGroupLeader: false, createTime: faker.date.past(), - updateTime: faker.date.past() + updateTime: faker.date.past(), + totalStudyTime: 0 } const updateFindResult = [ From 99d4cd4a1fda37c09a82d57538e677f3cff3861e Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Fri, 20 Feb 2026 01:56:41 +0900 Subject: [PATCH 09/17] feat(be): group tags, comments, join requests, timers, problem drafts --- apps/backend/prisma/schema.prisma | 100 ++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 0bdec2be4c..753b47fd2e 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -75,6 +75,10 @@ model User { workbook Workbook[] submission Submission[] useroauth UserOAuth? + groupComment GroupComment[] + problemDraft ProblemDraft[] + joinRequests GroupJoinRequest[] + groupTimer GroupTimer[] file File[] testSubmission TestSubmission[] UpdateHistory UpdateHistory[] @@ -156,6 +160,10 @@ model Group { sharedProblems Problem[] courseQnA CourseQnA[] CourseNotice CourseNotice[] + groupTag GroupTag[] + groupComment GroupComment[] + joinRequests GroupJoinRequest[] + groupTimer GroupTimer[] studyInfo StudyInfo? @@ -171,6 +179,89 @@ model GroupWhitelist { @@map("group_whitelist") } +model GroupTag { + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + groupId Int @map("group_id") + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + tagId Int @map("tag_id") + + @@id([groupId, tagId]) + @@map("group_tag") +} + +model GroupComment { + id Int @id @default(autoincrement()) + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + groupId Int @map("group_id") + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + createdById Int @map("created_by_id") + content String + isSecret Boolean @default(false) @map("is_secret") + isDeleted Boolean @default(false) @map("is_deleted") + parentCommentId Int? @map("parent_comment_id") + parent GroupComment? @relation("ReplyOn", fields: [parentCommentId], references: [id], onDelete: Cascade) + children GroupComment[] @relation("ReplyOn") + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + + @@map("group_comment") +} + +model ProblemDraft { + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int @map("user_id") + problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) + problemId Int @map("problem_id") + code Json + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + + @@id([userId, problemId]) + @@map("problem_draft") +} + +model GroupJoinRequest { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int @map("user_id") + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + groupId Int @map("group_id") + isAccepted Boolean? @map("is_accepted") + requestTime DateTime @default(now()) @map("request_time") + processTime DateTime? @map("process_time") + + @@unique([userId, groupId]) + @@map("group_join_request") +} + +enum TimerType { + ALL + PERSONAL +} + +enum TimerStatus { + RUNNING + PAUSED + STOPPED +} + +model GroupTimer { + id Int @id @default(autoincrement()) + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + groupId Int @map("group_id") + creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) + creatorId Int @map("creator_id") + type TimerType + status TimerStatus @default(STOPPED) + duration Int + remaining Int + startTime DateTime? @map("start_time") + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + + @@map("group_timer") +} + model CourseInfo { groupId Int @id @map("group_id") group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) @@ -183,6 +274,7 @@ model CourseInfo { office String? phoneNum String? @map("phone_num") week Int @default(16) //양수만 허용됨 + invitationCode String? @map("invitation_code") @@map("course_info") } @@ -293,6 +385,7 @@ model Problem { workbookProblem WorkbookProblem[] contestProblem ContestProblem[] submission Submission[] + problemDraft ProblemDraft[] announcement Announcement[] ContestQnA ContestQnA[] courseQnA CourseQnA[] @relation("ProblemToCourseQnA") @@ -402,6 +495,7 @@ model Tag { updateTime DateTime @updatedAt @map("update_time") problemTag ProblemTag[] + groupTag GroupTag[] @@map("tag") } @@ -744,6 +838,8 @@ model Submission { contestId Int? @map("contest_id") workbook Workbook? @relation(fields: [workbookId], references: [id]) workbookId Int? @map("workbook_id") + group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) + groupId Int? @map("group_id") /// code item structure /// { /// "id": number, @@ -763,6 +859,10 @@ model Submission { FirstCheckResult CheckResult[] @relation("FirstCheckSubmission") SecondCheckResult CheckResult[] @relation("SecondCheckSubmission") + FirstCheckResult CheckResult[] @relation("FirstCheckSubmission") + SecondCheckResult CheckResult[] @relation("SecondCheckSubmission") + + @@index([groupId]) @@map("submission") } From cf2d5966a83fcd7356450c40dda240a56909c87e Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Thu, 19 Feb 2026 17:03:44 +0000 Subject: [PATCH 10/17] feat(be): add migration for study group management and update schema --- .../migration.sql | 115 ++++++++++++++++++ apps/backend/prisma/schema.prisma | 92 +++++++------- 2 files changed, 160 insertions(+), 47 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260219170316_init_study_group/migration.sql diff --git a/apps/backend/prisma/migrations/20260219170316_init_study_group/migration.sql b/apps/backend/prisma/migrations/20260219170316_init_study_group/migration.sql new file mode 100644 index 0000000000..0a6ffa3ea8 --- /dev/null +++ b/apps/backend/prisma/migrations/20260219170316_init_study_group/migration.sql @@ -0,0 +1,115 @@ +-- CreateEnum +CREATE TYPE "public"."TimerType" AS ENUM ('ALL', 'PERSONAL'); + +-- CreateEnum +CREATE TYPE "public"."TimerStatus" AS ENUM ('RUNNING', 'PAUSED', 'STOPPED'); + +-- AlterTable +ALTER TABLE "public"."course_info" ADD COLUMN "invitation_code" TEXT; + +-- AlterTable +ALTER TABLE "public"."submission" ADD COLUMN "group_id" INTEGER; + +-- CreateTable +CREATE TABLE "public"."group_tag" ( + "group_id" INTEGER NOT NULL, + "tag_id" INTEGER NOT NULL, + + CONSTRAINT "group_tag_pkey" PRIMARY KEY ("group_id","tag_id") +); + +-- CreateTable +CREATE TABLE "public"."group_comment" ( + "id" SERIAL NOT NULL, + "group_id" INTEGER NOT NULL, + "created_by_id" INTEGER NOT NULL, + "content" TEXT NOT NULL, + "is_secret" BOOLEAN NOT NULL DEFAULT false, + "is_deleted" BOOLEAN NOT NULL DEFAULT false, + "parent_comment_id" INTEGER, + "create_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "update_time" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "group_comment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."problem_draft" ( + "user_id" INTEGER NOT NULL, + "problem_id" INTEGER NOT NULL, + "code" JSONB NOT NULL, + "create_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "update_time" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "problem_draft_pkey" PRIMARY KEY ("user_id","problem_id") +); + +-- CreateTable +CREATE TABLE "public"."group_join_request" ( + "id" SERIAL NOT NULL, + "user_id" INTEGER NOT NULL, + "group_id" INTEGER NOT NULL, + "is_accepted" BOOLEAN, + "request_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "process_time" TIMESTAMP(3), + + CONSTRAINT "group_join_request_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."group_timer" ( + "id" SERIAL NOT NULL, + "group_id" INTEGER NOT NULL, + "creator_id" INTEGER NOT NULL, + "type" "public"."TimerType" NOT NULL, + "status" "public"."TimerStatus" NOT NULL DEFAULT 'STOPPED', + "duration" INTEGER NOT NULL, + "remaining" INTEGER NOT NULL, + "start_time" TIMESTAMP(3), + "create_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "update_time" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "group_timer_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "group_join_request_user_id_group_id_key" ON "public"."group_join_request"("user_id", "group_id"); + +-- CreateIndex +CREATE INDEX "submission_group_id_idx" ON "public"."submission"("group_id"); + +-- AddForeignKey +ALTER TABLE "public"."group_tag" ADD CONSTRAINT "group_tag_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."group"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."group_tag" ADD CONSTRAINT "group_tag_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."group_comment" ADD CONSTRAINT "group_comment_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."group"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."group_comment" ADD CONSTRAINT "group_comment_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."group_comment" ADD CONSTRAINT "group_comment_parent_comment_id_fkey" FOREIGN KEY ("parent_comment_id") REFERENCES "public"."group_comment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."problem_draft" ADD CONSTRAINT "problem_draft_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."problem_draft" ADD CONSTRAINT "problem_draft_problem_id_fkey" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."group_join_request" ADD CONSTRAINT "group_join_request_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."group_join_request" ADD CONSTRAINT "group_join_request_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."group"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."group_timer" ADD CONSTRAINT "group_timer_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."group"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."group_timer" ADD CONSTRAINT "group_timer_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."submission" ADD CONSTRAINT "submission_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."group"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 753b47fd2e..cd910b604d 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -165,7 +165,8 @@ model Group { joinRequests GroupJoinRequest[] groupTimer GroupTimer[] - studyInfo StudyInfo? + studyInfo StudyInfo? + Submission Submission[] @@map("group") } @@ -190,19 +191,19 @@ model GroupTag { } model GroupComment { - id Int @id @default(autoincrement()) - group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) - groupId Int @map("group_id") - createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) - createdById Int @map("created_by_id") - content String - isSecret Boolean @default(false) @map("is_secret") - isDeleted Boolean @default(false) @map("is_deleted") - parentCommentId Int? @map("parent_comment_id") - parent GroupComment? @relation("ReplyOn", fields: [parentCommentId], references: [id], onDelete: Cascade) - children GroupComment[] @relation("ReplyOn") - createTime DateTime @default(now()) @map("create_time") - updateTime DateTime @updatedAt @map("update_time") + id Int @id @default(autoincrement()) + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + groupId Int @map("group_id") + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + createdById Int @map("created_by_id") + content String + isSecret Boolean @default(false) @map("is_secret") + isDeleted Boolean @default(false) @map("is_deleted") + parentCommentId Int? @map("parent_comment_id") + parent GroupComment? @relation("ReplyOn", fields: [parentCommentId], references: [id], onDelete: Cascade) + children GroupComment[] @relation("ReplyOn") + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") @@map("group_comment") } @@ -221,13 +222,13 @@ model ProblemDraft { } model GroupJoinRequest { - id Int @id @default(autoincrement()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId Int @map("user_id") - group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) - groupId Int @map("group_id") - isAccepted Boolean? @map("is_accepted") - requestTime DateTime @default(now()) @map("request_time") + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int @map("user_id") + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + groupId Int @map("group_id") + isAccepted Boolean? @map("is_accepted") + requestTime DateTime @default(now()) @map("request_time") processTime DateTime? @map("process_time") @@unique([userId, groupId]) @@ -246,34 +247,34 @@ enum TimerStatus { } model GroupTimer { - id Int @id @default(autoincrement()) - group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) - groupId Int @map("group_id") - creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) - creatorId Int @map("creator_id") - type TimerType - status TimerStatus @default(STOPPED) - duration Int - remaining Int - startTime DateTime? @map("start_time") - createTime DateTime @default(now()) @map("create_time") - updateTime DateTime @updatedAt @map("update_time") + id Int @id @default(autoincrement()) + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + groupId Int @map("group_id") + creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) + creatorId Int @map("creator_id") + type TimerType + status TimerStatus @default(STOPPED) + duration Int + remaining Int + startTime DateTime? @map("start_time") + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") @@map("group_timer") } model CourseInfo { - groupId Int @id @map("group_id") - group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) - courseNum String @map("course_num") - classNum Int? @map("class_num") - professor String - semester String - email String? - website String? - office String? - phoneNum String? @map("phone_num") - week Int @default(16) //양수만 허용됨 + groupId Int @id @map("group_id") + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + courseNum String @map("course_num") + classNum Int? @map("class_num") + professor String + semester String + email String? + website String? + office String? + phoneNum String? @map("phone_num") + week Int @default(16) //양수만 허용됨 invitationCode String? @map("invitation_code") @@map("course_info") @@ -859,9 +860,6 @@ model Submission { FirstCheckResult CheckResult[] @relation("FirstCheckSubmission") SecondCheckResult CheckResult[] @relation("SecondCheckSubmission") - FirstCheckResult CheckResult[] @relation("FirstCheckSubmission") - SecondCheckResult CheckResult[] @relation("SecondCheckSubmission") - @@index([groupId]) @@map("submission") } From cc4064e15089a4c25882b0cd172b18c79aeb236a Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Thu, 19 Feb 2026 17:15:25 +0000 Subject: [PATCH 11/17] feat(be): add invitationCode to user data and groupId to submissions --- .../apps/admin/src/group/group.service.spec.ts | 3 ++- .../apps/client/src/group/group.service.spec.ts | 13 +++++++++---- .../group/interface/user-group-data.interface.ts | 1 + .../submission/test/submission-pub.service.spec.ts | 3 ++- .../submission/test/submission-sub.service.spec.ts | 3 ++- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/backend/apps/admin/src/group/group.service.spec.ts b/apps/backend/apps/admin/src/group/group.service.spec.ts index 17a581292f..e57663246d 100644 --- a/apps/backend/apps/admin/src/group/group.service.spec.ts +++ b/apps/backend/apps/admin/src/group/group.service.spec.ts @@ -53,7 +53,8 @@ const courseInfo = { email: 'johndoe@example.com', website: 'https://example.com', office: 'Room 301', - phoneNum: '010-3333-2222' + phoneNum: '010-3333-2222', + invitationCode: null } const group = { diff --git a/apps/backend/apps/client/src/group/group.service.spec.ts b/apps/backend/apps/client/src/group/group.service.spec.ts index b9f526b406..4f4aba7feb 100644 --- a/apps/backend/apps/client/src/group/group.service.spec.ts +++ b/apps/backend/apps/client/src/group/group.service.spec.ts @@ -110,7 +110,8 @@ describe('GroupService', () => { email: 'example01@skku.edu', website: 'https://seclab.com', office: null, - phoneNum: null + phoneNum: null, + invitationCode: null }, isGroupLeader: true, isJoined: true @@ -151,7 +152,8 @@ describe('GroupService', () => { email: 'example01@skku.edu', website: 'https://seclab.com', office: null, - phoneNum: null + phoneNum: null, + invitationCode: null }, isGroupLeader: true, isJoined: true @@ -221,7 +223,8 @@ describe('GroupService', () => { email: 'example01@skku.edu', website: 'https://seclab.com', office: null, - phoneNum: null + phoneNum: null, + invitationCode: null }, memberNum: 11, isGroupLeader: true @@ -256,7 +259,8 @@ describe('GroupService', () => { const userGroupData: UserGroupData = { userId, groupId, - isGroupLeader: false + isGroupLeader: false, + totalStudyTime: 0 } expect(res) @@ -317,6 +321,7 @@ describe('GroupService', () => { userId, groupId, isGroupLeader: false, + totalStudyTime: 0, createTime: undefined, updateTime: undefined }) diff --git a/apps/backend/apps/client/src/group/interface/user-group-data.interface.ts b/apps/backend/apps/client/src/group/interface/user-group-data.interface.ts index e6d2c4e6d5..7fd0346b94 100644 --- a/apps/backend/apps/client/src/group/interface/user-group-data.interface.ts +++ b/apps/backend/apps/client/src/group/interface/user-group-data.interface.ts @@ -2,4 +2,5 @@ export interface UserGroupData { userId: number groupId: number isGroupLeader: boolean + totalStudyTime?: number } 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..b0ad781a1c 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 @@ -24,7 +24,8 @@ const submission: Submission & { submissionResult: SubmissionResult[] } = { ...submissions[0], codeSize: 1000, submissionResult: [], - score: new Prisma.Decimal(100) + score: new Prisma.Decimal(100), + groupId: null } describe('SubmissionPublicationService', () => { diff --git a/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts b/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts index 8ec6274814..6d0bb18879 100644 --- a/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts +++ b/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts @@ -46,7 +46,8 @@ const submission: Submission & { submissionResult: SubmissionResult[] } = { ...submissions[0], codeSize: 1000, submissionResult: [submissionResults[0], submissionResults[1]], - score: new Prisma.Decimal(100) + score: new Prisma.Decimal(100), + groupId: null } const contestSubmission = { From f220e3486d747c29d5a6fa6ac52a8610392128da Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Thu, 19 Feb 2026 17:21:40 +0000 Subject: [PATCH 12/17] feat(be): add invitationCode field to user data retrieval in group service --- apps/backend/apps/client/src/group/group.service.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/backend/apps/client/src/group/group.service.ts b/apps/backend/apps/client/src/group/group.service.ts index c8cc7adb49..f9fc726c26 100644 --- a/apps/backend/apps/client/src/group/group.service.ts +++ b/apps/backend/apps/client/src/group/group.service.ts @@ -81,7 +81,8 @@ export class GroupService { email: true, phoneNum: true, office: true, - website: true + website: true, + invitationCode: true } } } @@ -90,6 +91,10 @@ export class GroupService { } }) + if (isJoined) { + console.log('DEBUG: isJoined', JSON.stringify(isJoined, null, 2)) + } + if (!isJoined) { const filter = invited ? 'allowJoinWithURL' : 'showOnList' const group = await this.prisma.group.findUniqueOrThrow({ @@ -109,7 +114,8 @@ export class GroupService { courseNum: true, classNum: true, professor: true, - semester: true + semester: true, + invitationCode: true } } } From 79f599432558ee1f9cec519deab56961c3cb47c0 Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Thu, 19 Feb 2026 17:25:38 +0000 Subject: [PATCH 13/17] refactor(be): remove debug logging and unused invitationCode field in getCourse method --- apps/backend/apps/client/src/group/group.service.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/backend/apps/client/src/group/group.service.ts b/apps/backend/apps/client/src/group/group.service.ts index f9fc726c26..e21909dad7 100644 --- a/apps/backend/apps/client/src/group/group.service.ts +++ b/apps/backend/apps/client/src/group/group.service.ts @@ -91,10 +91,6 @@ export class GroupService { } }) - if (isJoined) { - console.log('DEBUG: isJoined', JSON.stringify(isJoined, null, 2)) - } - if (!isJoined) { const filter = invited ? 'allowJoinWithURL' : 'showOnList' const group = await this.prisma.group.findUniqueOrThrow({ @@ -114,8 +110,7 @@ export class GroupService { courseNum: true, classNum: true, professor: true, - semester: true, - invitationCode: true + semester: true } } } From ff13a07cd73d8f4d07f811fbafde5539cd9e7a6d Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Thu, 19 Feb 2026 17:39:32 +0000 Subject: [PATCH 14/17] feat(be): implement study group management with CreateStudyDto and UpdateStudyDto --- .../apps/client/src/group/dto/study.dto.ts | 79 ++++ .../apps/client/src/group/group.module.ts | 6 +- .../apps/client/src/group/study.controller.ts | 119 ++++++ .../apps/client/src/group/study.service.ts | 340 ++++++++++++++++++ 4 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 apps/backend/apps/client/src/group/dto/study.dto.ts create mode 100644 apps/backend/apps/client/src/group/study.controller.ts create mode 100644 apps/backend/apps/client/src/group/study.service.ts diff --git a/apps/backend/apps/client/src/group/dto/study.dto.ts b/apps/backend/apps/client/src/group/dto/study.dto.ts new file mode 100644 index 0000000000..6100f6aded --- /dev/null +++ b/apps/backend/apps/client/src/group/dto/study.dto.ts @@ -0,0 +1,79 @@ +import { Type } from 'class-transformer' +import { + IsArray, + IsBoolean, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Min, + ValidateNested +} from 'class-validator' + +class StudyConfig { + @IsBoolean() + @IsNotEmpty() + showOnList: boolean + + @IsBoolean() + @IsNotEmpty() + allowJoinFromSearch: boolean + + @IsBoolean() + @IsNotEmpty() + allowJoinWithURL: boolean + + @IsBoolean() + @IsNotEmpty() + requireApprovalBeforeJoin: boolean +} + +export class CreateStudyDto { + @IsNotEmpty() + @IsString() + groupName: string + + @IsOptional() + @IsString() + description?: string + + @IsOptional() + @IsInt() + @Min(1) + capacity?: number + + @IsOptional() + @IsArray() + @IsInt({ each: true }) + tagIds?: number[] + + @IsNotEmpty() + @ValidateNested() + @Type(() => StudyConfig) + config: StudyConfig +} + +export class UpdateStudyDto { + @IsOptional() + @IsString() + groupName?: string + + @IsOptional() + @IsString() + description?: string + + @IsOptional() + @IsInt() + @Min(1) + capacity?: number + + @IsOptional() + @IsArray() + @IsInt({ each: true }) + tagIds?: number[] + + @IsOptional() + @ValidateNested() + @Type(() => StudyConfig) + config?: StudyConfig +} diff --git a/apps/backend/apps/client/src/group/group.module.ts b/apps/backend/apps/client/src/group/group.module.ts index 9aa16f0f22..e3a0c7a72f 100644 --- a/apps/backend/apps/client/src/group/group.module.ts +++ b/apps/backend/apps/client/src/group/group.module.ts @@ -2,11 +2,13 @@ import { Module } from '@nestjs/common' import { RolesModule } from '@libs/auth' import { CourseController, GroupController } from './group.controller' import { GroupService, CourseService } from './group.service' +import { StudyController } from './study.controller' +import { StudyService } from './study.service' @Module({ imports: [RolesModule], - controllers: [GroupController, CourseController], - providers: [GroupService, CourseService], + controllers: [GroupController, CourseController, StudyController], + providers: [GroupService, CourseService, StudyService], exports: [GroupService] }) export class GroupModule {} diff --git a/apps/backend/apps/client/src/group/study.controller.ts b/apps/backend/apps/client/src/group/study.controller.ts new file mode 100644 index 0000000000..a266ef3021 --- /dev/null +++ b/apps/backend/apps/client/src/group/study.controller.ts @@ -0,0 +1,119 @@ +import { + Body, + Controller, + Delete, + Get, + Logger, + Param, + Patch, + Post, + Req, + UseGuards +} from '@nestjs/common' +import { AuthenticatedRequest, GroupMemberGuard } from '@libs/auth' +import { GroupIDPipe, RequiredIntPipe } from '@libs/pipe' +import { CreateStudyDto, UpdateStudyDto } from './dto/study.dto' +import { StudyService } from './study.service' + +@Controller('study') +export class StudyController { + private readonly logger = new Logger(StudyController.name) + + constructor(private readonly studyService: StudyService) {} + + @Post() + async createStudyGroup( + @Req() req: AuthenticatedRequest, + @Body() dto: CreateStudyDto + ) { + return this.studyService.createStudyGroup(req.user.id, dto) + } + + @Get('joined') + async getJoinedStudyGroups(@Req() req: AuthenticatedRequest) { + return this.studyService.getJoinedStudyGroups(req.user.id) + } + + @Get(':groupId') + async getStudyGroup( + @Req() req: AuthenticatedRequest, + @Param('groupId', GroupIDPipe) groupId: number + ) { + return this.studyService.getStudyGroup(groupId, req.user.id) + } + + @Patch(':groupId') + @UseGuards(GroupMemberGuard) + async updateStudyGroup( + @Req() req: AuthenticatedRequest, + @Param('groupId', GroupIDPipe) groupId: number, + @Body() dto: UpdateStudyDto + ) { + return this.studyService.updateStudyGroup(groupId, req.user.id, dto) + } + + @Delete(':groupId') + @UseGuards(GroupMemberGuard) + async deleteStudyGroup( + @Req() req: AuthenticatedRequest, + @Param('groupId', GroupIDPipe) groupId: number + ) { + return this.studyService.deleteStudyGroup(groupId, req.user.id) + } + + @Post(':groupId/join') + async joinStudyGroup( + @Req() req: AuthenticatedRequest, + @Param('groupId', GroupIDPipe) groupId: number + ) { + return this.studyService.joinStudyGroup(groupId, req.user.id) + } + + @Get(':groupId/join') + @UseGuards(GroupMemberGuard) + async getJoinRequests( + @Req() req: AuthenticatedRequest, + @Param('groupId', GroupIDPipe) groupId: number + ) { + return this.studyService.getJoinRequests(groupId, req.user.id) + } + + @Post(':groupId/join/:userId/accept') + @UseGuards(GroupMemberGuard) + async acceptJoinRequest( + @Req() req: AuthenticatedRequest, + @Param('groupId', GroupIDPipe) groupId: number, + @Param('userId', new RequiredIntPipe('userId')) targetUserId: number + ) { + return this.studyService.handleJoinRequest( + groupId, + req.user.id, + targetUserId, + true + ) + } + + @Post(':groupId/join/:userId/reject') + @UseGuards(GroupMemberGuard) + async rejectJoinRequest( + @Req() req: AuthenticatedRequest, + @Param('groupId', GroupIDPipe) groupId: number, + @Param('userId', new RequiredIntPipe('userId')) targetUserId: number + ) { + return this.studyService.handleJoinRequest( + groupId, + req.user.id, + targetUserId, + false + ) + } + + @Delete(':groupId/leave') + @UseGuards(GroupMemberGuard) + async leaveStudyGroup( + @Req() req: AuthenticatedRequest, + @Param('groupId', GroupIDPipe) groupId: number + ) { + return this.studyService.leaveStudyGroup(groupId, req.user.id) + } +} diff --git a/apps/backend/apps/client/src/group/study.service.ts b/apps/backend/apps/client/src/group/study.service.ts new file mode 100644 index 0000000000..16e3d9d027 --- /dev/null +++ b/apps/backend/apps/client/src/group/study.service.ts @@ -0,0 +1,340 @@ +import { Injectable } from '@nestjs/common' +import { GroupType } from '@prisma/client' +import { + ConflictFoundException, + EntityNotExistException, + ForbiddenAccessException +} from '@libs/exception' +import { PrismaService } from '@libs/prisma' +import type { CreateStudyDto, UpdateStudyDto } from './dto/study.dto' + +@Injectable() +export class StudyService { + constructor(private readonly prisma: PrismaService) {} + + async createStudyGroup(userId: number, dto: CreateStudyDto) { + const { tagIds, capacity, ...rest } = dto + + return this.prisma.$transaction(async (tx) => { + const group = await tx.group.create({ + data: { + groupName: rest.groupName, + groupType: GroupType.Study, + description: rest.description, + config: rest.config as object, + studyInfo: { + create: { capacity: capacity ?? 10 } + }, + userGroup: { + create: { userId, isGroupLeader: true } + }, + ...(tagIds?.length && { + groupTag: { + createMany: { + data: tagIds.map((tagId) => ({ tagId })) + } + } + }) + }, + select: { + id: true, + groupName: true, + description: true, + config: true, + studyInfo: { select: { capacity: true } }, + groupTag: { + select: { tag: { select: { id: true, name: true } } } + } + } + }) + + return { + ...group, + tags: group.groupTag.map(({ tag }) => tag), + groupTag: undefined + } + }) + } + + async getJoinedStudyGroups(userId: number) { + const records = await this.prisma.userGroup.findMany({ + where: { + userId, + group: { groupType: GroupType.Study, NOT: { id: 1 } } + }, + select: { + isGroupLeader: true, + group: { + select: { + id: true, + groupName: true, + description: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { select: { userGroup: true } }, + studyInfo: { select: { capacity: true } }, + groupTag: { + select: { tag: { select: { id: true, name: true } } } + } + } + } + }, + orderBy: { createTime: 'desc' } + }) + + return records.map(({ group, isGroupLeader }) => ({ + id: group.id, + groupName: group.groupName, + description: group.description, + memberNum: group._count.userGroup, + capacity: group.studyInfo?.capacity, + isGroupLeader, + tags: group.groupTag.map(({ tag }) => tag) + })) + } + + async getStudyGroup(groupId: number, userId: number) { + const group = await this.prisma.group.findUnique({ + where: { id: groupId, groupType: GroupType.Study }, + select: { + id: true, + groupName: true, + description: true, + config: true, + createTime: true, + studyInfo: { select: { capacity: true } }, + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { select: { userGroup: true } }, + groupTag: { + select: { tag: { select: { id: true, name: true } } } + }, + userGroup: { + where: { userId }, + select: { isGroupLeader: true, totalStudyTime: true } + } + } + }) + + if (!group) { + throw new EntityNotExistException('StudyGroup') + } + + const membership = group.userGroup[0] + + return { + id: group.id, + groupName: group.groupName, + description: group.description, + config: group.config, + createTime: group.createTime, + capacity: group.studyInfo?.capacity, + memberNum: group._count.userGroup, + tags: group.groupTag.map(({ tag }) => tag), + isJoined: !!membership, + isGroupLeader: membership?.isGroupLeader ?? false, + totalStudyTime: membership?.totalStudyTime ?? 0 + } + } + + async updateStudyGroup(groupId: number, userId: number, dto: UpdateStudyDto) { + await this.assertLeader(groupId, userId) + + const { tagIds, capacity, config, ...rest } = dto + + return this.prisma.$transaction(async (tx) => { + if (tagIds !== undefined) { + await tx.groupTag.deleteMany({ where: { groupId } }) + if (tagIds.length) { + await tx.groupTag.createMany({ + data: tagIds.map((tagId) => ({ groupId, tagId })) + }) + } + } + + if (capacity !== undefined) { + await tx.studyInfo.update({ + where: { groupId }, + data: { capacity } + }) + } + + return tx.group.update({ + where: { id: groupId }, + data: { + ...rest, + ...(config && { config: config as object }) + }, + select: { + id: true, + groupName: true, + description: true, + config: true + } + }) + }) + } + + async deleteStudyGroup(groupId: number, userId: number) { + await this.assertLeader(groupId, userId) + return this.prisma.group.delete({ where: { id: groupId } }) + } + + async joinStudyGroup(groupId: number, userId: number) { + const group = await this.prisma.group.findUnique({ + where: { id: groupId, groupType: GroupType.Study }, + select: { + config: true, + studyInfo: { select: { capacity: true } }, + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { select: { userGroup: true } } + } + }) + + if (!group) { + throw new EntityNotExistException('StudyGroup') + } + + if ( + group.studyInfo?.capacity && + group._count.userGroup >= group.studyInfo.capacity + ) { + throw new ConflictFoundException('Group is full') + } + + const existing = await this.prisma.userGroup.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_groupId: { userId, groupId } + } + }) + + if (existing) { + throw new ConflictFoundException('Already joined this group') + } + + if (group.config?.['requireApprovalBeforeJoin']) { + const pendingRequest = await this.prisma.groupJoinRequest.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_groupId: { userId, groupId } + } + }) + + if (pendingRequest && pendingRequest.isAccepted === null) { + throw new ConflictFoundException('Already requested to join') + } + + await this.prisma.groupJoinRequest.upsert({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_groupId: { userId, groupId } + }, + create: { userId, groupId }, + update: { isAccepted: null, requestTime: new Date() } + }) + + return { isJoined: false } + } + + await this.prisma.userGroup.create({ + data: { userId, groupId, isGroupLeader: false } + }) + + return { isJoined: true } + } + + async getJoinRequests(groupId: number, userId: number) { + await this.assertLeader(groupId, userId) + + return this.prisma.groupJoinRequest.findMany({ + where: { groupId, isAccepted: null }, + select: { + userId: true, + requestTime: true, + user: { select: { username: true, studentId: true } } + }, + orderBy: { requestTime: 'asc' } + }) + } + + async handleJoinRequest( + groupId: number, + leaderId: number, + targetUserId: number, + accept: boolean + ) { + await this.assertLeader(groupId, leaderId) + + const request = await this.prisma.groupJoinRequest.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_groupId: { userId: targetUserId, groupId } + } + }) + + if (!request || request.isAccepted !== null) { + throw new EntityNotExistException('JoinRequest') + } + + return this.prisma.$transaction(async (tx) => { + await tx.groupJoinRequest.update({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_groupId: { userId: targetUserId, groupId } + }, + data: { isAccepted: accept, processTime: new Date() } + }) + + if (accept) { + await tx.userGroup.create({ + data: { userId: targetUserId, groupId, isGroupLeader: false } + }) + } + + return { accepted: accept } + }) + } + + async leaveStudyGroup(groupId: number, userId: number) { + const membership = await this.prisma.userGroup.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_groupId: { userId, groupId } + }, + select: { isGroupLeader: true } + }) + + if (!membership) { + throw new EntityNotExistException('Membership') + } + + if (membership.isGroupLeader) { + const leaderCount = await this.prisma.userGroup.count({ + where: { groupId, isGroupLeader: true } + }) + if (leaderCount <= 1) { + throw new ConflictFoundException('One or more leaders are required') + } + } + + return this.prisma.userGroup.delete({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_groupId: { userId, groupId } + } + }) + } + + private async assertLeader(groupId: number, userId: number) { + const membership = await this.prisma.userGroup.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_groupId: { userId, groupId } + }, + select: { isGroupLeader: true } + }) + + if (!membership?.isGroupLeader) { + throw new ForbiddenAccessException('Only leaders can perform this action') + } + } +} From f7a6b7352976fdb2ed7c4a7bedf238ba3b36248b Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Fri, 20 Feb 2026 06:27:22 +0000 Subject: [PATCH 15/17] feat(be): add study group management with StudyModule, StudyController, and StudyService --- apps/backend/apps/client/src/app.module.ts | 2 + .../apps/client/src/group/group.module.ts | 6 +-- .../src/{group => study}/dto/study.dto.ts | 5 ++ .../client/src/study/study.controller.spec.ts | 25 ++++++++++ .../src/{group => study}/study.controller.ts | 28 ++++++++++- .../apps/client/src/study/study.module.ts | 12 +++++ .../client/src/study/study.service.spec.ts | 47 +++++++++++++++++++ .../src/{group => study}/study.service.ts | 46 ++++++++++++++++++ 8 files changed, 166 insertions(+), 5 deletions(-) rename apps/backend/apps/client/src/{group => study}/dto/study.dto.ts (94%) create mode 100644 apps/backend/apps/client/src/study/study.controller.spec.ts rename apps/backend/apps/client/src/{group => study}/study.controller.ts (78%) create mode 100644 apps/backend/apps/client/src/study/study.module.ts create mode 100644 apps/backend/apps/client/src/study/study.service.spec.ts rename apps/backend/apps/client/src/{group => study}/study.service.ts (88%) diff --git a/apps/backend/apps/client/src/app.module.ts b/apps/backend/apps/client/src/app.module.ts index 1d495b87f2..e075df1d5c 100644 --- a/apps/backend/apps/client/src/app.module.ts +++ b/apps/backend/apps/client/src/app.module.ts @@ -24,6 +24,7 @@ import { GroupModule } from './group/group.module' import { NoticeModule } from './notice/notice.module' import { NotificationModule } from './notification/notification.module' import { ProblemModule } from './problem/problem.module' +import { StudyModule } from './study/study.module' import { SubmissionModule } from './submission/submission.module' import { UserModule } from './user/user.module' import { WorkbookModule } from './workbook/workbook.module' @@ -43,6 +44,7 @@ import { WorkbookModule } from './workbook/workbook.module' AuthModule, ContestModule, GroupModule, + StudyModule, NoticeModule, ProblemModule, SubmissionModule, diff --git a/apps/backend/apps/client/src/group/group.module.ts b/apps/backend/apps/client/src/group/group.module.ts index e3a0c7a72f..9aa16f0f22 100644 --- a/apps/backend/apps/client/src/group/group.module.ts +++ b/apps/backend/apps/client/src/group/group.module.ts @@ -2,13 +2,11 @@ import { Module } from '@nestjs/common' import { RolesModule } from '@libs/auth' import { CourseController, GroupController } from './group.controller' import { GroupService, CourseService } from './group.service' -import { StudyController } from './study.controller' -import { StudyService } from './study.service' @Module({ imports: [RolesModule], - controllers: [GroupController, CourseController, StudyController], - providers: [GroupService, CourseService, StudyService], + controllers: [GroupController, CourseController], + providers: [GroupService, CourseService], exports: [GroupService] }) export class GroupModule {} diff --git a/apps/backend/apps/client/src/group/dto/study.dto.ts b/apps/backend/apps/client/src/study/dto/study.dto.ts similarity index 94% rename from apps/backend/apps/client/src/group/dto/study.dto.ts rename to apps/backend/apps/client/src/study/dto/study.dto.ts index 6100f6aded..e2d47cafaa 100644 --- a/apps/backend/apps/client/src/group/dto/study.dto.ts +++ b/apps/backend/apps/client/src/study/dto/study.dto.ts @@ -77,3 +77,8 @@ export class UpdateStudyDto { @Type(() => StudyConfig) config?: StudyConfig } + +export class UpsertDraftDto { + @IsNotEmpty() + code: object +} diff --git a/apps/backend/apps/client/src/study/study.controller.spec.ts b/apps/backend/apps/client/src/study/study.controller.spec.ts new file mode 100644 index 0000000000..7353035d2a --- /dev/null +++ b/apps/backend/apps/client/src/study/study.controller.spec.ts @@ -0,0 +1,25 @@ +import { Test, type TestingModule } from '@nestjs/testing' +import { expect } from 'chai' +import { RolesService } from '@libs/auth' +import { StudyController } from './study.controller' +import { StudyService } from './study.service' + +describe('StudyController', () => { + let controller: StudyController + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [StudyController], + providers: [ + { provide: StudyService, useValue: {} }, + { provide: RolesService, useValue: {} } + ] + }).compile() + + controller = module.get(StudyController) + }) + + it('should be defined', () => { + expect(controller).to.be.ok + }) +}) diff --git a/apps/backend/apps/client/src/group/study.controller.ts b/apps/backend/apps/client/src/study/study.controller.ts similarity index 78% rename from apps/backend/apps/client/src/group/study.controller.ts rename to apps/backend/apps/client/src/study/study.controller.ts index a266ef3021..c44b5f58a7 100644 --- a/apps/backend/apps/client/src/group/study.controller.ts +++ b/apps/backend/apps/client/src/study/study.controller.ts @@ -7,12 +7,13 @@ import { Param, Patch, Post, + Put, Req, UseGuards } from '@nestjs/common' import { AuthenticatedRequest, GroupMemberGuard } from '@libs/auth' import { GroupIDPipe, RequiredIntPipe } from '@libs/pipe' -import { CreateStudyDto, UpdateStudyDto } from './dto/study.dto' +import { CreateStudyDto, UpdateStudyDto, UpsertDraftDto } from './dto/study.dto' import { StudyService } from './study.service' @Controller('study') @@ -116,4 +117,29 @@ export class StudyController { ) { return this.studyService.leaveStudyGroup(groupId, req.user.id) } + + @Put('draft/:problemId') + async upsertDraft( + @Req() req: AuthenticatedRequest, + @Param('problemId', new RequiredIntPipe('problemId')) problemId: number, + @Body() dto: UpsertDraftDto + ) { + return this.studyService.upsertDraft(req.user.id, problemId, dto.code) + } + + @Get('draft/:problemId') + async getDraft( + @Req() req: AuthenticatedRequest, + @Param('problemId', new RequiredIntPipe('problemId')) problemId: number + ) { + return this.studyService.getDraft(req.user.id, problemId) + } + + @Delete('draft/:problemId') + async deleteDraft( + @Req() req: AuthenticatedRequest, + @Param('problemId', new RequiredIntPipe('problemId')) problemId: number + ) { + return this.studyService.deleteDraft(req.user.id, problemId) + } } diff --git a/apps/backend/apps/client/src/study/study.module.ts b/apps/backend/apps/client/src/study/study.module.ts new file mode 100644 index 0000000000..1c0236a291 --- /dev/null +++ b/apps/backend/apps/client/src/study/study.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { RolesModule } from '@libs/auth' +import { StudyController } from './study.controller' +import { StudyService } from './study.service' + +@Module({ + imports: [RolesModule], + controllers: [StudyController], + providers: [StudyService], + exports: [StudyService] +}) +export class StudyModule {} diff --git a/apps/backend/apps/client/src/study/study.service.spec.ts b/apps/backend/apps/client/src/study/study.service.spec.ts new file mode 100644 index 0000000000..2283e12a4a --- /dev/null +++ b/apps/backend/apps/client/src/study/study.service.spec.ts @@ -0,0 +1,47 @@ +import { Test, type TestingModule } from '@nestjs/testing' +import { expect } from 'chai' +import { + PrismaService, + PrismaTestService, + type FlatTransactionClient +} from '@libs/prisma' +import { StudyService } from './study.service' + +describe('StudyService', () => { + let service: StudyService + let prisma: PrismaTestService + let transaction: FlatTransactionClient + + before(async function () { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StudyService, + PrismaTestService, + { + provide: PrismaService, + useExisting: PrismaTestService + } + ] + }).compile() + service = module.get(StudyService) + prisma = module.get(PrismaTestService) + }) + + beforeEach(async () => { + transaction = await prisma.$begin() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(service as any).prisma = transaction + }) + + after(async () => { + await prisma.$disconnect() + }) + + afterEach(async () => { + await transaction.$rollback() + }) + + it('should be defined', () => { + expect(service).to.be.ok + }) +}) diff --git a/apps/backend/apps/client/src/group/study.service.ts b/apps/backend/apps/client/src/study/study.service.ts similarity index 88% rename from apps/backend/apps/client/src/group/study.service.ts rename to apps/backend/apps/client/src/study/study.service.ts index 16e3d9d027..30412f25ab 100644 --- a/apps/backend/apps/client/src/group/study.service.ts +++ b/apps/backend/apps/client/src/study/study.service.ts @@ -324,6 +324,52 @@ export class StudyService { }) } + async upsertDraft(userId: number, problemId: number, code: object) { + return this.prisma.problemDraft.upsert({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_problemId: { userId, problemId } + }, + create: { userId, problemId, code }, + update: { code } + }) + } + + async getDraft(userId: number, problemId: number) { + const draft = await this.prisma.problemDraft.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_problemId: { userId, problemId } + } + }) + + if (!draft) { + throw new EntityNotExistException('ProblemDraft') + } + + return draft + } + + async deleteDraft(userId: number, problemId: number) { + const draft = await this.prisma.problemDraft.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_problemId: { userId, problemId } + } + }) + + if (!draft) { + throw new EntityNotExistException('ProblemDraft') + } + + return this.prisma.problemDraft.delete({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_problemId: { userId, problemId } + } + }) + } + private async assertLeader(groupId: number, userId: number) { const membership = await this.prisma.userGroup.findUnique({ where: { From cec61356b780ae4a2fec2c0eae7d967e52ed9050 Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Sun, 8 Mar 2026 12:56:43 +0000 Subject: [PATCH 16/17] fix(be): standardize spacing in User model relations --- apps/backend/prisma/schema.prisma | 54 ++++++++++++++++--------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 8517dfdb34..24a6a96dc3 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -63,32 +63,32 @@ 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? - groupComment GroupComment[] - problemDraft ProblemDraft[] - joinRequests GroupJoinRequest[] - groupTimer GroupTimer[] - file File[] - testSubmission TestSubmission[] - UpdateHistory UpdateHistory[] - createdQnAs ContestQnA[] @relation("CreatedByUser") - createdQnAComments ContestQnAComment[] @relation("CreatedByUser") - NotificationRecord NotificationRecord[] - PushSubscription PushSubscription[] - CourseNotice CourseNotice[] - CourseNoticeComment CourseNoticeComment[] - CheckRequest CheckRequest[] + userProfile UserProfile? + userGroup UserGroup[] + notice Notice[] + problem Problem[] + assignment Assignment[] + assignmentRecord AssignmentRecord[] + contest Contest[] + userContest UserContest[] + contestRecord ContestRecord[] + workbook Workbook[] + submission Submission[] + useroauth UserOAuth? + groupComment GroupComment[] + problemDraft ProblemDraft[] + joinRequests GroupJoinRequest[] + groupTimer GroupTimer[] + file File[] + testSubmission TestSubmission[] + UpdateHistory UpdateHistory[] + createdQnAs ContestQnA[] @relation("CreatedByUser") + createdQnAComments ContestQnAComment[] @relation("CreatedByUser") + NotificationRecord NotificationRecord[] + PushSubscription PushSubscription[] + CourseNotice CourseNotice[] + CourseNoticeComment CourseNoticeComment[] + CheckRequest CheckRequest[] userProfile UserProfile? userGroup UserGroup[] notice Notice[] @@ -1150,6 +1150,8 @@ model StudyInfo { group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) @@map("study_info") +} + enum PolygonProblemStatus { Draft Ready From d2c1f52bfee2bc510cf89e9814820c6b38b3f1a5 Mon Sep 17 00:00:00 2001 From: Choi-Jung-Hyeon Date: Sun, 8 Mar 2026 12:56:50 +0000 Subject: [PATCH 17/17] fix(be): remove unused fields from User model for optimization --- apps/backend/prisma/schema.prisma | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 24a6a96dc3..d0ec402e73 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -89,28 +89,6 @@ model User { CourseNotice CourseNotice[] CourseNoticeComment CourseNoticeComment[] CheckRequest CheckRequest[] - 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[] polygonProblems PolygonProblem[] @relation("PolygonProblemOwner") polygonCollaborations PolygonCollaborator[] @relation("PolygonCollaborator") polygonApprovalRequests PolygonApprovalRequest[] @relation("PolygonApprovalRequester")