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.spec.ts b/apps/backend/apps/admin/src/group/group.service.spec.ts index d5a252fc80..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 = { @@ -66,14 +67,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', @@ -635,4 +638,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/apps/admin/src/group/group.service.ts b/apps/backend/apps/admin/src/group/group.service.ts index 1fd1af6744..28d0c62b46 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 { @@ -101,6 +102,47 @@ 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 + } + }, + studyInfo: { + create: { + capacity: capacity ?? 10, + invitationCode: isPrivate ? invitationCode : null + } + } + } + }) + + return group + } + /** * 강좌(Course) 목록을 조회합니다. * 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..1db1a2debd 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,9 +18,24 @@ 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: 'Max people (default: 10)' }) + capacity?: number + + @Field(() => String, { + nullable: true, + description: 'Private invitation code' + }) + 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..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 } from '@nestjs/graphql' -import { ObjectType } from '@nestjs/graphql' +import { Field, Int, ObjectType } from '@nestjs/graphql' import { Group } from '@generated' @ObjectType() @@ -17,10 +16,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/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/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 = [ 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.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/group.service.ts b/apps/backend/apps/client/src/group/group.service.ts index c8cc7adb49..e21909dad7 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 } } } 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/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/apps/client/src/study/dto/study.dto.ts b/apps/backend/apps/client/src/study/dto/study.dto.ts new file mode 100644 index 0000000000..e2d47cafaa --- /dev/null +++ b/apps/backend/apps/client/src/study/dto/study.dto.ts @@ -0,0 +1,84 @@ +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 +} + +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/study/study.controller.ts b/apps/backend/apps/client/src/study/study.controller.ts new file mode 100644 index 0000000000..c44b5f58a7 --- /dev/null +++ b/apps/backend/apps/client/src/study/study.controller.ts @@ -0,0 +1,145 @@ +import { + Body, + Controller, + Delete, + Get, + Logger, + Param, + Patch, + Post, + Put, + Req, + UseGuards +} from '@nestjs/common' +import { AuthenticatedRequest, GroupMemberGuard } from '@libs/auth' +import { GroupIDPipe, RequiredIntPipe } from '@libs/pipe' +import { CreateStudyDto, UpdateStudyDto, UpsertDraftDto } 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) + } + + @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/study/study.service.ts b/apps/backend/apps/client/src/study/study.service.ts new file mode 100644 index 0000000000..30412f25ab --- /dev/null +++ b/apps/backend/apps/client/src/study/study.service.ts @@ -0,0 +1,386 @@ +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 } + } + }) + } + + 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: { + // 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') + } + } +} 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 = { 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 = { 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/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 5ebc8002b5..d0ec402e73 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[] @@ -125,6 +129,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") } @@ -158,6 +164,13 @@ model Group { sharedProblems Problem[] courseQnA CourseQnA[] CourseNotice CourseNotice[] + groupTag GroupTag[] + groupComment GroupComment[] + joinRequests GroupJoinRequest[] + groupTimer GroupTimer[] + + studyInfo StudyInfo? + Submission Submission[] @@map("group") } @@ -171,18 +184,102 @@ 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) - 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") } @@ -293,6 +390,7 @@ model Problem { workbookProblem WorkbookProblem[] contestProblem ContestProblem[] submission Submission[] + problemDraft ProblemDraft[] announcement Announcement[] ContestQnA ContestQnA[] courseQnA CourseQnA[] @relation("ProblemToCourseQnA") @@ -402,6 +500,7 @@ model Tag { updateTime DateTime @updatedAt @map("update_time") problemTag ProblemTag[] + groupTag GroupTag[] @@map("tag") } @@ -744,6 +843,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 +864,7 @@ model Submission { FirstCheckResult CheckResult[] @relation("FirstCheckSubmission") SecondCheckResult CheckResult[] @relation("SecondCheckSubmission") + @@index([groupId]) @@map("submission") } @@ -1018,6 +1120,16 @@ model CourseQnAComment { @@map("course_qna_comment") } +model StudyInfo { + groupId Int @id @map("group_id") + capacity Int @default(10) + invitationCode String? @map("invitation_code") + + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + + @@map("study_info") +} + enum PolygonProblemStatus { Draft Ready