Skip to content

feat(be): implement studyGroup main API#3471

Open
nhjbest22 wants to merge 52 commits intomainfrom
t2594-add-studyGroup-main
Open

feat(be): implement studyGroup main API#3471
nhjbest22 wants to merge 52 commits intomainfrom
t2594-add-studyGroup-main

Conversation

@nhjbest22
Copy link
Contributor

Description

StudyGroup main API의 기본적인 뼈대를 구현해보았습니다.

우선 StudyGroup에서 활용할 schema를 추가하고 API를 구현했습니다.

Additional context

  1. Group 모델에 groupTag 필드를 추가하였습니다.

    model Group {
    id Int @id @default(autoincrement())
    groupName String @map("group_name")
    groupType GroupType @default(Course) @map("group_type")
    courseInfo CourseInfo?
    description String?
    /// config default value
    /// {
    /// "showOnList": true, // show on 'all groups' list
    /// "allowJoinFromSearch": true, // can join from 'all groups' list. set to false if `showOnList` is false
    /// "allowJoinWithURL": false,
    /// "requireApprovalBeforeJoin": true
    /// }
    config Json
    createTime DateTime @default(now()) @map("create_time")
    updateTime DateTime @updatedAt @map("update_time")
    userGroup UserGroup[]
    assignment Assignment[]
    workbook Workbook[]
    GroupWhitelist GroupWhitelist[]
    sharedProblems Problem[]
    courseQnA CourseQnA[]
    CourseNotice CourseNotice[]
    groupTag GroupTag[]

    해당 필드는 특정 스터디 그룹에서 진행하고 있는 문제들의 태그들의 합집합으로 이루어져 있으며,
    별도의 필드 없이도 Group -> Problem -> ProblemTag -> Tag.name 를 통해 얻을 수는 있습니다.
    다만 위와 같이 Tag의 이름을 찾을 경우 3번의 join 연산이 StudyGroup 조회 시 필요해 DB에 너무 많은 부담이 갈 것으로 예상됩니다.

    따라서, 의도적으로 Group 모델 내부에 groupTag 필드를 추가해 Group -> GroupTag -> Tag.name 를 통해 그룹에 속한 Tag 명을 찾을 수 있도록 하였습니다.

    현재로서는 StudyGroup에 속한 문제 리스트가 바뀔때 마다 GroupTag를 업데이트 하도록 하는 방식을 사용했습니다.

  2. StudyGroup을 수정하는 PATCH 요청에 대해 UseGroupLeaderGuard를 적용했습니다.

    @Patch(':groupId')
    @UseGroupLeaderGuard()
    async updateStudyGroup(
    @Param('groupId', GroupIDPipe) groupId: number,
    @Body() updateStudyDto: UpdateStudyDto
    ) {
    return await this.studyService.updateStudyGroup(groupId, updateStudyDto)
    }

    현재로서는 Group의 Leader가 아닌 일반 참여자들도 StudyGroup을 수정하는 것이 가능하게 해야 할지 정해진 것이 없어, 우선은 Leader만 이 StudyGroup을 수정 및 문제를 추가 및 제거할 수 있도록 하였습니다.


Before submitting the PR, please make sure you do the following

@Query('take', new DefaultValuePipe(10), new RequiredIntPipe('take'))
take: number
) {
return await this.studyService.getStudyGroups({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

나중에 페이지네이션 필요할 수도 있을거같은데 GroupService.getGroups() 처럼 total도 받는것도 나쁘지 않을 것 같습니다! 물론 명세서가 커서 기반일지 오프셋 방식일지는 모르겠지만...!

export class StudyService {
constructor(private readonly prisma: PrismaService) {}

async createStudyGroup(userId: number, dto: CreateStudyDto) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getStudyGroups, getStudyGroup이랑 다르게 create랑 update는 raw 모델 그대로 나가는거같은데 의도된건가요?

Copy link
Contributor Author

@nhjbest22 nhjbest22 Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

async updateComment({
userId,
id,
commentId,
updateCourseNoticeCommentDto
}: {
userId: number
id: number
commentId: number
updateCourseNoticeCommentDto: UpdateCourseNoticeCommentDto
}) {
if (await this.isForbiddenNotice({ id, userId })) {
throw new ForbiddenAccessException('it is not accessible course notice')
}
const comment = await this.prisma.courseNoticeComment.findUnique({
where: {
id: commentId
},
select: {
createdById: true
}
})
if (!comment) {
throw new EntityNotExistException('CouseNoticeComment')
}
if (comment.createdById !== userId) {
throw new ForbiddenException('it is not accessible comment')
}
return await this.prisma.courseNoticeComment.update({
where: {
id: commentId,
courseNoticeId: id,
createdById: userId
},
data: {
content: updateCourseNoticeCommentDto.content,
isSecret: updateCourseNoticeCommentDto.isSecret
}
})
}

async updateUserEmail(
req: AuthenticatedRequest,
updateUserEmailDto: UpdateUserEmailDto
): Promise<User> {
const { email } = await this.verifyJwtFromRequestHeader(req)
if (email != updateUserEmailDto.email) {
this.logger.debug(
{
verifiedEmail: email,
requestedEmail: updateUserEmailDto.email
},
'updateUserEmail - fail (different from the verified email)'
)
throw new UnprocessableDataException('The email is not authenticated one')
}
await this.deletePinFromCache(emailAuthenticationPinCacheKey(email))
try {
const user = await this.prisma.user.update({
where: { id: req.user.id },
data: {
email: updateUserEmailDto.email
}
})
this.logger.debug(user, 'updateUserEmail')
return user
} catch (error) {
if (
error instanceof PrismaClientKnownRequestError &&
error.code == 'P2025'
)
throw new EntityNotExistException('User')
throw error
}
}

async registerContest({
contestId,
userId,
invitationCode
}: {
contestId: number
userId: number
invitationCode?: string
}) {
const [contest, hasRegistered] = await Promise.all([
this.prisma.contest.findUniqueOrThrow({
where: {
id: contestId
},
select: {
registerDueTime: true,
invitationCode: true
}
}),
this.prisma.contestRecord.findFirst({
where: { userId, contestId },
select: { id: true }
})
])
if (contest.invitationCode && contest.invitationCode !== invitationCode) {
throw new ConflictFoundException('Invalid invitation code')
}
if (hasRegistered) {
throw new ConflictFoundException('Already participated this contest')
}
const now = new Date()
if (now >= contest.registerDueTime) {
throw new ConflictFoundException(
'Cannot participate in the contest after the registration deadline'
)
}
return await this.prisma.$transaction(async (prisma) => {
const contestRecord = await prisma.contestRecord.create({
data: { contestId, userId }
})
await prisma.userContest.create({
data: { contestId, userId, role: ContestRole.Participant }
})
return contestRecord
})
}

client 내부에 Post, Patch 요청을 찾아보니깐 대부분이 Prisma 객체를 그대로 return 하는 경우가 많이 있어서 위와 같이 구현하였습니다

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RyuRaseul 이거 그냥 이대로 둬도 괜찮을 것 같은데 어떻게 생각하세요

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

근데 형님 친절한 코드 붙여넣기 감동이네요 근데 저희 말 편하게 하면 안되나요 친해지고 싶은데~

Copy link
Contributor

@Choi-Jung-Hyeon Choi-Jung-Hyeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저에게는 룩굿투미인데 도현이는 언제쯤 룩해줄까요

capacity: studyGroup.studyInfo?.capacity,
tags: studyGroup.groupTag.map((tag) => tag.tag.name),
isPublic: !studyGroup.studyInfo?.invitationCode,
isJoined: userId ? studyGroup._count.userGroup > 0 : false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 그냥 해결된걸로 하시죠 ㅋㅋ 문제 없을듯

export class StudyService {
constructor(private readonly prisma: PrismaService) {}

async createStudyGroup(userId: number, dto: CreateStudyDto) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RyuRaseul 이거 그냥 이대로 둬도 괜찮을 것 같은데 어떻게 생각하세요

export class StudyService {
constructor(private readonly prisma: PrismaService) {}

async createStudyGroup(userId: number, dto: CreateStudyDto) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

근데 형님 친절한 코드 붙여넣기 감동이네요 근데 저희 말 편하게 하면 안되나요 친해지고 싶은데~

@nhjbest22
Copy link
Contributor Author

지난번 Init 회의 이후에 수정사항이 있어서 해당 내역 반영했습니다.

  1. 스터디 그룹을 통해 진행된 누적 스터디 시간 삭제
    UserGroup 모델에서 totalStudyTime 필드를 삭제했습니다. 기존에는 RabbitMQ를 사용해서 CronJob을 실행할 생각이었으나 굳이 필요가 없다는 의견이 있어 제거하였습니다.

  2. 스터디 생성 시 진행 시간을 'hours' 형태(1시간 ~ 24시간)로 입력받고 입력된 시간이 지나면 해당 스터디 그룹이 종료되는 것으로 변경되었습니다.
    이를 반영하기 위해 CreateStudyDto 내부에 durationHours 필드를 추가하고, 스터디 그룹 내부의 서비스 함수에도 이를 반영했습니다.
    특히, 스터디 그룹 전체 목록 조회 시, 종료된 스터디 그룹은 반환할 필요가 없다는 의견이 있어 현재 진행중인 스터디 그룹만 반환되도록 변경하였습니다.

    where: {
    groupType: GroupType.Study,
    studyInfo: {
    endTime: {
    gt: now
    }
    }
    },

Copy link
Contributor

@Choi-Jung-Hyeon Choi-Jung-Hyeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

도현이는 또 한참 안보겠지 이제

let newEndTime: Date | undefined = undefined
if (durationHours !== undefined) {
newEndTime = new Date(studyGroup.createTime)
newEndTime.setHours(newEndTime.getHours() + durationHours)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저 궁금한게 생성 시점부터 총 N시간으로 무조건 계산하는 건가요? 수정 시점부터 N시간 연장은 안되는거죠

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 스터디 그룹 생성 시에, 종료 예정 시간을 최대 24시간까지 설정이 가능한데, 이에 대한 PATCH 요청으로 시간을 추가 연장하는 것은 좀 애매할 것 같습니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아마도 시간 연장에 대한 별도의 API와 FE에서의 입력창이 구현되어야 할 것 같아요!

let newEndTime: Date | undefined = undefined
if (durationHours !== undefined) {
newEndTime = new Date(studyGroup.createTime)
newEndTime.setHours(newEndTime.getHours() + durationHours)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그리고 역대급 오지랖이긴 한데 서머타임 DST를 쓰는 나라에서는... setHours()를 쓰면 안된대요 ㅋㅋ .getTime() + durationHours * 1000 * 60 * 60 으로 해야된대요 개웃기네 ㅋㅋㅋ

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그런데 한국은 서머타임 적용이 안되어서 크게 문제는 없지 않아요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

committed 694a64b

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서버가 한국에 있다는 보장이 없어서 일단 반영해뒀습니다 ㅋㅋ

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

잘 수정됐네요 좋은데요

}))
}

async getStudyGroup(groupId: number, userId: number) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

얘는 진행중인 스터디 검사 안해도 되나요? s붙은 애는 하는거같아요

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

어제 코드 수정만 하고 커밋을 안했네요 ㅋㅋ

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

committed 032059b

@hjkim24
Copy link
Contributor

hjkim24 commented Mar 25, 2026

/gemini review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new 'Study' module, enabling users to create, manage, and join study groups. This includes new DTOs for study group data, a controller for API endpoints, and a service layer for business logic and database interactions. Database schema changes were also made to support study group information and group-specific tags. Feedback includes optimizing the getStudyGroups method to prevent performance issues by efficiently fetching leader information and member counts, and adjusting the invitationCode validation in UpdateStudyDto to correctly handle null values for transitioning study groups from private to public.

Comment on lines +106 to +115
userGroup: {
select: {
user: {
select: {
username: true
}
},
isGroupLeader: true
}
},

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

getStudyGroups 메소드에서 각 스터디 그룹의 모든 멤버를 가져오는 방식은 성능 문제를 일으킬 수 있습니다. 스터디 그룹에 멤버가 많을 경우 불필요하게 많은 데이터를 조회하게 됩니다.

리더의 정보와 총 멤버 수는 Prisma 쿼리에서 더 효율적으로 가져올 수 있습니다. userGroup 관계에서는 리더만 조회하고, 총 멤버 수는 _count를 사용하는 것을 제안합니다.

// In prisma.group.findMany
select: {
  // ... other fields
  userGroup: {
    where: { isGroupLeader: true },
    select: {
      user: {
        select: {
          username: true
        }
      }
    }
  },
  _count: {
    select: {
      userGroup: true // Total members
    }
  },
  // ... other fields
}

// Then in the map function
leader: studyGroup.userGroup[0]?.user.username,
currentMember: studyGroup._count.userGroup,

isJoined 필드를 처리하기 위해 현재처럼 조건부 _count를 사용하면 총 멤버 수를 가져오는 _count와 충돌하게 됩니다. isJoined 상태는 별도의 쿼리로 한 번에 가져오거나, userGroup 관계를 통해 확인하는 방식으로 변경하는 것이 좋습니다. 예를 들어, getStudyGroups의 결과를 받은 후 groupId 목록으로 userGroup에서 현재 사용자의 참여 정보를 한 번에 조회할 수 있습니다.

이러한 변경은 데이터베이스 부하를 크게 줄여줄 것입니다.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제미나이봇을 추가했습니다~ 개지림~

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 각 스터디 그룹의 멤버가 최대 10명 정도로 제한되는 것으로 알고 있어서, 크게 DB에 부담이 되지는 않을 것 같습니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

또한, 현재 종료된 스터디 그룹에 대해서는 DB 조회를 무시하기 때문에 큰 병목 현상이 발생하지는 않을 것 같습니다.

Comment on lines +32 to +35
@IsString()
@IsOptional()
@Length(6, 6)
invitationCode?: string | null

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

invitationCode의 유효성 검사가 null 값을 허용하지 않는 것 같습니다. UpdateStudyDto에서 스터디를 비공개에서 공개로 전환하기 위해 invitationCodenull로 설정하는 경우가 있을 수 있습니다. 현재 @IsString() 데코레이터 때문에 null 값을 보내면 유효성 검사에 실패하게 됩니다.

null 값을 허용하도록 유효성 검사를 수정하는 것을 제안합니다. class-validatorValidateIf를 사용하면 null이 아닐 경우에만 문자열 관련 유효성 검사를 수행하도록 할 수 있습니다. 또한 class-validator에서 ValidateIf를 import해야 합니다.

Suggested change
@IsString()
@IsOptional()
@Length(6, 6)
invitationCode?: string | null
@IsOptional()
@ValidateIf((o, value) => value !== null)
@IsString()
@Length(6, 6)
invitationCode?: string | null

Copy link
Contributor Author

@nhjbest22 nhjbest22 Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

update시 정상적으로 null 값을 허용하는 것을 확인했습니다.
실제로 DB에도 잘 반영되구요

[gemini-code-assist]가 확실하진 않네용

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Review PLZ 🙏

Development

Successfully merging this pull request may close these issues.

3 participants