Skip to content

feat(be): implement studyGroup main API#3471

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

feat(be): implement studyGroup main API#3471
nhjbest22 wants to merge 38 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

@nhjbest22 nhjbest22 requested a review from RyuRaseul March 6, 2026 10:53
@nhjbest22 nhjbest22 self-assigned this Mar 6, 2026
@github-project-automation github-project-automation bot moved this to Pending ✋ in Codedang Mar 6, 2026
@nhjbest22 nhjbest22 moved this from Pending ✋ to Review PLZ 🙏 in Codedang Mar 6, 2026
const tags = await this.prisma.problemTag.findMany({
where: {
problemId: {
in: problemIds
Copy link
Contributor

Choose a reason for hiding this comment

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

problemIds가 optional이라서 undefined면 전체 ProblemTag를 조회하게 될 거 같아요! 그래 전체 태그가 그룹에 연결될지도??

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 d986dee

throw new EntityNotExistException('StudyGroup')
}

if (dto.capacity && dto.capacity < studyGroup._count.userGroup)
Copy link
Contributor

Choose a reason for hiding this comment

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

capacity가 0으로 설정되면 검증을 건너뛸거같아요! 그래서 나중에 studyinfo.update에서 capacity: 0으로 들어가면 정원이 0명이 될거같아요.
(나중에 찾아보니까 DTO 검증에서 capacity 0 검사를 하긴 하네요.)

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.

userId가 null이면 _count에서 타입 에러 날거같아요. 아근데 삼항연산자가 뒤에 또 있긴 하네요.

Copy link
Contributor

Choose a reason for hiding this comment

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

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

}
},
sharedProblems: {
connect: problemIds?.map((problmeId) => ({ id: problmeId }))
Copy link
Contributor

Choose a reason for hiding this comment

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

problemId 오타!!

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 b904e46

async deleteStudyGroup(groupId: number) {
const studyGroup = await this.prisma.group.findUnique({
where: {
id: groupId
Copy link
Contributor

Choose a reason for hiding this comment

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

여기엔 groupType: GroupType.Study 없어도 되나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

사실 groupId가 unique하기 때문에 크게 문제는 없어 보이는데, 가독성을 위해서 추가해두도록 하겠습니다.

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 240c2da

Copy link
Contributor

Choose a reason for hiding this comment

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

지금은 should be defined만 확인하는데 이것도 나중에 추가되면 좋을거 같아요.

createStudyGroup: 정상 생성, problemIds 없이 생성
joinStudyGroupById: 정원 초과, 이미 가입, 초대 코드 불일치
updateStudyGroup: capacity < 현재 멤버 수
deleteStudyGroup: 존재하지 않는 그룹 삭제 시도

async joinStudyGroupById(
@Req() req: AuthenticatedRequest,
@Param('groupId', GroupIDPipe) groupId: number,
@Query('invitation') invitation?: string
Copy link
Contributor

Choose a reason for hiding this comment

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

저도 오늘 찾아보다가 갑자기 생각난건데 초대코드는 get보다는 request body로 전달하는게 낫지 않을까요? url로 받으면 브라우저 히스토리랑 서버 로그에 기록된다고 하더라고요. 근데 초대코드가 사실 대학교 수업용으로 수업에서 뿌리는 거라 그정도로 보안에 신경써야할지는 좀 고민해봐야겠네요.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

아마 쿼리 스트링으로 받는 것 때문에 그러시는 것 같은데, 크게 문제 없지 않을까요?
브라우저 히스토리에 남아도 요청자 브라우저에만 남고, 서버 로그도 저희만 볼 수 있어서 크게 문제 없을 것 같아용


@Controller('study')
export class StudyController {
private readonly logger = new Logger(StudyController.name)
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.

추후에 Logging 작업 시에 사용하려고 넣어뒀습니다.
지금 당장은 사용하지 않으니깐 빼도록 할까요?

}),
...(problemIds && {
groupTag: {
deleteMany: {},
Copy link
Contributor

Choose a reason for hiding this comment

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

$transaction 감싸는거 어때요? create때문에 race condition 안나려나?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

찾아보니깐 prisma 내부에서 중첩 쓰기 작업이 이루어지면 자동으로 단일 트랜잭션으로 묶어서 처리하는 것 같더라구요.
그래서 해당 부분은 문제가 되지 않을 것 같아요.

다만, updateStudyGroup 함수 상단 부분에서 studyGroup을 조회하는데 DB 조회랑 중첩 쓰기 작업 사이에 다른 요청으로 인해 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.

committed 6ead07e

@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.

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

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.

2 participants