From 5ed8041e17ae12edc233c1e7266bd123e6102dd4 Mon Sep 17 00:00:00 2001 From: juheon429 Date: Thu, 2 Apr 2026 07:00:12 +0000 Subject: [PATCH 01/12] feat(be): implement collaborator service --- .../collaborator/collaborator.service.ts | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts new file mode 100644 index 0000000000..0d295ff929 --- /dev/null +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts @@ -0,0 +1,316 @@ +import { Injectable } from '@nestjs/common' +import { CollaboratorRole, CollaboratorStatus } from '@prisma/client' +import { + EntityNotExistException, + ForbiddenAccessException, + DuplicateFoundException, + UnprocessableDataException +} from '@libs/exception' +import { PrismaService } from '@libs/prisma' +import { + CollaboratorInput, + CollaboratorUpdateInput +} from './model/collaborator.input' + +@Injectable() +export class CollaboratorService { + constructor(private readonly prisma: PrismaService) {} + + /** + * 해당 polygon 문제에 협업자를 초대합니다. + * + * 협업자 초대는 1. 해당 문제의 소유자 2. status: Active, role:Editor 인 경우에만 가능 + * + * @param {number} inviterId 초대자의 id + * @param {number} polygonId 생성 문제의 id + * @param {CollaboratorInput} model 협업자 id, role + * @returns {polygonCollaborator} 협업자 정보 + * @throws {EntityNotExistException} 아래와 같은 경우 발생합니다. + * -해당 polygonId에 해당하는 문제가 존재하지 않는 경우 + * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. + * -초대자가 문제의 소유자가 아니면서 status: Active, role:Editor가 아닌 경우 + * @throws {DuplicateFoundException} 아래와 같은 경우 발생합니다. + * -이미 초대된 협업자를 초대한 경우 + * -문제 소유자를 초대한 경우 + */ + async inviteCollaborator( + inviterId: number, + polygonId: number, + input: CollaboratorInput + ) { + const { userEmail, role } = input + const user = await this.prisma.user.findUnique({ + where: { email: userEmail }, + select: { id: true } + }) + if (!user) { + throw new EntityNotExistException('User not found') + } + const userId = user.id + + const problem = await this.prisma.polygonProblem.findUnique({ + where: { id: polygonId }, + select: { createdById: true } + }) + if (!problem) throw new EntityNotExistException('PolygonProblem not found') + + const isOwner = problem.createdById === inviterId + + if (!isOwner) { + const inviterInfo = await this.prisma.polygonCollaborator.findFirst({ + where: { + problemId: polygonId, + userId: inviterId + }, + select: { status: true, role: true } + }) + const canInvite = + inviterInfo?.status === CollaboratorStatus.Active && + inviterInfo?.role === CollaboratorRole.Editor + if (!canInvite) { + throw new ForbiddenAccessException( + 'No permission to invite collaborator' + ) + } + } + + const existing = await this.prisma.polygonCollaborator.findFirst({ + where: { + problemId: polygonId, + userId + }, + select: { id: true } + }) + if (existing || userId === problem.createdById) { + throw new DuplicateFoundException('Collaborator') + } + const status = isOwner + ? CollaboratorStatus.Active + : CollaboratorStatus.Pending + + return await this.prisma.polygonCollaborator.create({ + data: { + problemId: polygonId, + userId, + role, + status + } + }) + } + + /** + * Status에 따른 협업자 목록을 반환합니다. + * + * status : Pending(수락 대기 중), Active(활성화 됨) + * + * @param {number} polygonId 생성 문제의 id + * @param {CollaboratorStatus} status 협업자의 상태 + * @returns {olygonCollaborator[]} 협업자 목록 + */ + async getCollaboratorsByStatus( + polygonId: number, + status: CollaboratorStatus + ) { + const collaborators = await this.prisma.polygonCollaborator.findMany({ + where: { + problemId: polygonId, + status + }, + select: { + role: true, + user: { + select: { + username: true, + id: true, + email: true + } + } + } + }) + + return collaborators.map((c) => ({ + username: c.user.username, + id: c.user.id, + email: c.user.email, + role: c.role + })) + } + + /** + * 문제 소유자가 협업 요청을 수락합니다. + * + * @param {number} createdById 문제 소유자의 id + * @param {number} polygonId 생성 문제의 id + * @param {number} userId 협업자의 id + * @returns {polygonCollaborator} 협업자 정보 + * @throws {EntityNotExistException} 아래와 같은 경우 발생합니다. + * -해당 polygonId에 해당하는 문제가 존재하지 않는 경우 + * -해당 userId에 해당하는 협업자가 존재하지 않는 경우 + * @throws {UnprocessableDataException} 아래와 같은 경우 발생합니다. + * -협업자의 status가 Pending이 아닌 경우 + * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. + * -createdById가 해당 문제의 소유자가 아닌 경우 + */ + async approveCollaborator( + createdById: number, + polygonId: number, + userId: number + ) { + const problem = await this.prisma.polygonProblem.findUnique({ + where: { id: polygonId }, + select: { createdById: true } + }) + if (!problem) throw new EntityNotExistException('PolygonProblem not found') + + if (problem.createdById !== createdById) { + throw new ForbiddenAccessException('No permission to approve/reject') + } + const collaborator = await this.prisma.polygonCollaborator.findFirst({ + where: { problemId: polygonId, userId }, + select: { id: true, status: true } + }) + if (!collaborator) { + throw new EntityNotExistException('Collaborator not found') + } + + if (collaborator.status !== CollaboratorStatus.Pending) { + throw new UnprocessableDataException('Invitation is not pending') + } + + return await this.prisma.polygonCollaborator.update({ + where: { id: collaborator.id }, + data: { status: CollaboratorStatus.Active } + }) + } + + /** + * 문제 소유자가 협업 요청을 거절합니다. + * + * @param {number} createdById 문제 소유자의 id + * @param {number} polygonId 생성 문제의 id + * @param {number} userId 협업자의 id + * @returns {polygonCollaborator} 삭제 협업자 정보 + * @throws {EntityNotExistException} 아래와 같은 경우 발생합니다. + * -해당 polygonId에 해당하는 문제가 존재하지 않는 경우 + * -해당 userId에 해당하는 협업자가 존재하지 않는 경우 + * @throws {UnprocessableDataException} 아래와 같은 경우 발생합니다. + * -협업자의 status가 Pending이 아닌 경우 + * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. + * -createdById가 해당 문제의 소유자가 아닌 경우 + */ + async rejectCollaborator( + createdById: number, + polygonId: number, + userId: number + ) { + const problem = await this.prisma.polygonProblem.findUnique({ + where: { id: polygonId }, + select: { createdById: true } + }) + if (!problem) throw new EntityNotExistException('PolygonProblem not found') + + if (problem.createdById !== createdById) { + throw new ForbiddenAccessException('No permission to approve/reject') + } + + const collaborator = await this.prisma.polygonCollaborator.findFirst({ + where: { problemId: polygonId, userId }, + select: { id: true, status: true } + }) + if (!collaborator) + throw new EntityNotExistException('Collaborator not found') + + if (collaborator.status !== CollaboratorStatus.Pending) { + throw new UnprocessableDataException('Invitation is not pending') + } + + return await this.prisma.polygonCollaborator.delete({ + where: { id: collaborator.id } + }) + } + + /** + * 협업자의 role을 변경합니다. + * + * role : Viewer, Editor로만 변경 + * + * @param {number} inviterId 초대자의 id + * @param {number} polygonId 생성 문제의 id + * @param {CollaboratorInput} input 협업자 id, role + * @returns {polygonCollaborator} 협업자 정보 + * @throws {EntityNotExistException} 아래와 같은 경우 발생합니다. + * -해당 polygonId에 해당하는 문제가 존재하지 않는 경우 + * -해당 userId에 해당하는 협업자가 존재하지 않는 경우 + * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. + * -invitorId가 해당 문제의 소유자가 아닌 경우 + */ + async updateCollaboratorRole( + inviterId: number, + polygonId: number, + input: CollaboratorUpdateInput + ) { + const { userId, role } = input + const problem = await this.prisma.polygonProblem.findUnique({ + where: { id: polygonId }, + select: { createdById: true } + }) + if (!problem) throw new EntityNotExistException('PolygonProblem not found') + + const isOwner = problem.createdById === inviterId + if (!isOwner) + throw new ForbiddenAccessException('No permission to update role') + + const collaborator = await this.prisma.polygonCollaborator.findFirst({ + where: { problemId: polygonId, userId }, + select: { id: true, status: true } + }) + if (!collaborator) { + throw new EntityNotExistException('Collaborator not found') + } + return await this.prisma.polygonCollaborator.update({ + where: { id: collaborator.id }, + data: { role } + }) + } + + /** + * 협업자를 제거합니다. + * 협업자 제거는 문제 소유자만 가능합니다. + * + * @param {number} createdById 문제 소유자의 id + * @param {number} polygonId 생성 문제의 id + * @param {number} userId 협업자의 id + * @returns {polygonCollaborator} 삭제된 협업자 정보 + * @throws {EntityNotExistException} 아래와 같은 경우 발생합니다. + * -해당 문제 소유자와 polygonId에 해당하는 문제가 존재하지 않는 경우 + * -해당 userId에 대항하는 협업자가 존재하지 않는 경우 + * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. + * -createdById가 해당 문제의 소유자가 아닌 경우 + */ + async removeCollaborator( + createdById: number, + polygonId: number, + userId: number + ) { + const problem = await this.prisma.polygonProblem.findUnique({ + where: { id: polygonId }, + select: { createdById: true } + }) + if (!problem) throw new EntityNotExistException('PolygonProblem not found') + + if (problem.createdById !== createdById) { + throw new ForbiddenAccessException('No permission to remove collaborator') + } + + const collaborator = await this.prisma.polygonCollaborator.findFirst({ + where: { problemId: polygonId, userId }, + select: { id: true } + }) + if (!collaborator) { + throw new EntityNotExistException('Collaborator not found') + } + return await this.prisma.polygonCollaborator.delete({ + where: { id: collaborator.id } + }) + } +} From 0cb0e819238a82155aa26a15e6324485664bc3df Mon Sep 17 00:00:00 2001 From: juheon429 Date: Thu, 2 Apr 2026 07:06:09 +0000 Subject: [PATCH 02/12] feat(be): implement collaborator resolver --- .../collaborator/collaborator.resolver.ts | 99 +++++++++++++++++++ .../collaborator/model/collaborator.input.ts | 21 ++++ .../apps/admin/src/polygon/polygon.module.ts | 9 +- 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 apps/backend/apps/admin/src/polygon/collaborator/collaborator.resolver.ts create mode 100644 apps/backend/apps/admin/src/polygon/collaborator/model/collaborator.input.ts diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.resolver.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.resolver.ts new file mode 100644 index 0000000000..f1f03303e9 --- /dev/null +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.resolver.ts @@ -0,0 +1,99 @@ +import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql' +import { CollaboratorStatus } from '@prisma/client' +import { AuthenticatedRequest } from '@libs/auth' +import { IDValidationPipe } from '@libs/pipe' +import { CollaboratorService } from './collaborator.service' +import { + CollaboratorInput, + CollaboratorUpdateInput +} from './model/collaborator.input' + +@Resolver() +export class CollaboratorResolver { + constructor(private readonly collaboratorService: CollaboratorService) {} + + @Mutation() + async inviteCollaborator( + @Context('req') req: AuthenticatedRequest, + @Args('polygonId', { type: () => Int }, IDValidationPipe) polygonId: number, + @Args('input') input: CollaboratorInput + ) { + return await this.collaboratorService.inviteCollaborator( + req.user.id, + polygonId, + input + ) + } + + @Query() + async getActiveCollaborator( + @Args('polygonId', { type: () => Int }, IDValidationPipe) polygonId: number + ) { + return await this.collaboratorService.getCollaboratorsByStatus( + polygonId, + CollaboratorStatus.Active + ) + } + + @Query() + async getPendingCollaborator( + @Args('polygonId', { type: () => Int }, IDValidationPipe) polygonId: number + ) { + return await this.collaboratorService.getCollaboratorsByStatus( + polygonId, + CollaboratorStatus.Pending + ) + } + + @Mutation() + async approveInvite( + @Context('req') req: AuthenticatedRequest, + @Args('polygonId', { type: () => Int }, IDValidationPipe) polygonId: number, + @Args('userId', { type: () => Int }, IDValidationPipe) userId: number + ) { + return await this.collaboratorService.approveCollaborator( + req.user.id, + polygonId, + userId + ) + } + + @Mutation() + async rejectInvite( + @Context('req') req: AuthenticatedRequest, + @Args('polygonId', { type: () => Int }, IDValidationPipe) polygonId: number, + @Args('userId', { type: () => Int }, IDValidationPipe) userId: number + ) { + return await this.collaboratorService.rejectCollaborator( + req.user.id, + polygonId, + userId + ) + } + + @Mutation() + async updateCollaboratorRole( + @Context('req') req: AuthenticatedRequest, + @Args('polygonId', { type: () => Int }, IDValidationPipe) polygonId: number, + @Args('input') input: CollaboratorUpdateInput + ) { + return await this.collaboratorService.updateCollaboratorRole( + req.user.id, + polygonId, + input + ) + } + + @Mutation() + async removeCollaborator( + @Context('req') req: AuthenticatedRequest, + @Args('polygonId', { type: () => Int }, IDValidationPipe) polygonId: number, + @Args('userId', { type: () => Int }, IDValidationPipe) userId: number + ) { + return await this.collaboratorService.removeCollaborator( + req.user.id, + polygonId, + userId + ) + } +} diff --git a/apps/backend/apps/admin/src/polygon/collaborator/model/collaborator.input.ts b/apps/backend/apps/admin/src/polygon/collaborator/model/collaborator.input.ts new file mode 100644 index 0000000000..f97c0643db --- /dev/null +++ b/apps/backend/apps/admin/src/polygon/collaborator/model/collaborator.input.ts @@ -0,0 +1,21 @@ +import { InputType } from '@nestjs/graphql' +import { CollaboratorRole } from '@generated' +import { IsEmail, IsEnum, IsInt } from 'class-validator' + +@InputType() +export class CollaboratorInput { + @IsEmail() + userEmail: string + + @IsEnum(CollaboratorRole) + role: CollaboratorRole +} + +@InputType() +export class CollaboratorUpdateInput { + @IsInt() + userId: number + + @IsEnum(CollaboratorRole) + role: CollaboratorRole +} diff --git a/apps/backend/apps/admin/src/polygon/polygon.module.ts b/apps/backend/apps/admin/src/polygon/polygon.module.ts index f45388d1c4..866e61a217 100644 --- a/apps/backend/apps/admin/src/polygon/polygon.module.ts +++ b/apps/backend/apps/admin/src/polygon/polygon.module.ts @@ -1,10 +1,17 @@ import { Module } from '@nestjs/common' import { RolesModule } from '@libs/auth' +import { CollaboratorResolver } from './collaborator/collaborator.resolver' +import { CollaboratorService } from './collaborator/collaborator.service' import { PolygonResolver } from './polygon.resolver' import { PolygonService } from './polygon.service' @Module({ imports: [RolesModule], - providers: [PolygonResolver, PolygonService] + providers: [ + PolygonResolver, + CollaboratorResolver, + PolygonService, + CollaboratorService + ] }) export class PolygonModule {} From d51262114adde346c8d80244025eaf3ca6493cf3 Mon Sep 17 00:00:00 2001 From: juheon429 Date: Thu, 2 Apr 2026 07:11:40 +0000 Subject: [PATCH 03/12] feat(be): add collaborator service unit test --- .../collaborator/collaborator.service.spec.ts | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.spec.ts diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.spec.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.spec.ts new file mode 100644 index 0000000000..f16cb1b48c --- /dev/null +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.spec.ts @@ -0,0 +1,258 @@ +import { Test, type TestingModule } from '@nestjs/testing' +import { CollaboratorRole, CollaboratorStatus } from '@generated' +import { expect } from 'chai' +import { stub } from 'sinon' +import { ForbiddenAccessException } from '@libs/exception' +import { PrismaService } from '@libs/prisma' +import { CollaboratorService } from './collaborator.service' + +const exampleOwner = { + id: 1, + username: 'owner', + email: 'owner@test.com' +} + +const exampleUser = { + id: 2, + username: 'invitee', + email: 'invitee@test.com' +} + +const exampleEditorUser = { + id: 3, + username: 'editor', + email: 'editor@test.com' +} + +const exampleViewerUser = { + id: 4, + username: 'viewer', + email: 'viewer@test.com' +} + +const examplePendingUser = { + id: 5, + username: 'pending', + email: 'pending@test.com' +} + +const exampleViewerCollaborator = { + id: 1, + problemId: 10, + userId: exampleViewerUser.id, + role: CollaboratorRole.Viewer, + status: CollaboratorStatus.Active +} + +const exampleEditorCollaborator = { + id: 2, + problemId: 10, + userId: exampleEditorUser.id, + role: CollaboratorRole.Editor, + status: CollaboratorStatus.Active +} + +const examplePendingCollaborator = { + id: 3, + problemId: 10, + userId: examplePendingUser.id, + role: CollaboratorRole.Viewer, + status: CollaboratorStatus.Pending +} + +const exampleCollaboratorList = [ + exampleViewerCollaborator, + exampleEditorCollaborator, + examplePendingCollaborator +] + +const exampleProblem = { + id: 10, + createdById: exampleOwner.id, + polygonCollaborators: exampleCollaboratorList +} + +const exampleCollaboratorListByStatus = [ + { + role: exampleViewerCollaborator.role, + user: { + id: exampleViewerUser.id, + username: exampleViewerUser.username, + email: exampleViewerUser.email + } + }, + { + role: exampleEditorCollaborator.role, + user: { + id: exampleEditorUser.id, + username: exampleEditorUser.username, + email: exampleEditorUser.email + } + } +] + +const db = { + user: { + findUnique: stub() + }, + polygonProblem: { + findUnique: stub() + }, + polygonCollaborator: { + findFirst: stub(), + findMany: stub(), + create: stub(), + update: stub(), + delete: stub() + } +} + +describe('CollaboratorService', () => { + let service: CollaboratorService + + beforeEach(async () => { + db.user.findUnique.reset() + db.polygonProblem.findUnique.reset() + db.polygonCollaborator.findFirst.reset() + db.polygonCollaborator.findMany.reset() + db.polygonCollaborator.create.reset() + db.polygonCollaborator.update.reset() + db.polygonCollaborator.delete.reset() + const module: TestingModule = await Test.createTestingModule({ + providers: [CollaboratorService, { provide: PrismaService, useValue: db }] + }).compile() + + service = module.get(CollaboratorService) + }) + + it('should be defined', () => { + expect(service).to.be.ok + }) + + describe('inviteCollaborator', () => { + it('owner can invite collaborator', async () => { + db.user.findUnique.resolves({ id: exampleUser.id }) + db.polygonProblem.findUnique.resolves({ + createdById: exampleProblem.createdById + }) + + db.polygonCollaborator.findFirst.resolves(null) + db.polygonCollaborator.create.resolves(exampleViewerCollaborator) + + const result = await service.inviteCollaborator( + exampleOwner.id, + exampleProblem.id, + { + userEmail: exampleUser.email, + role: CollaboratorRole.Viewer + } + ) + expect(result).to.deep.equal(exampleViewerCollaborator) + }) + + it('Viewer cannot invite collaborator', async () => { + db.user.findUnique.resolves({ id: exampleUser.id }) + db.polygonProblem.findUnique.resolves({ + createdById: exampleProblem.createdById + }) + db.polygonCollaborator.findFirst.resolves(exampleViewerCollaborator) + + await expect( + service.inviteCollaborator(exampleViewerUser.id, exampleProblem.id, { + userEmail: exampleUser.email, + role: CollaboratorRole.Viewer + }) + ).to.be.rejectedWith(ForbiddenAccessException) + }) + }) + + describe('getCollaboratorsByStatus', () => { + it('return active collaborators', async () => { + db.polygonCollaborator.findMany.resolves(exampleCollaboratorListByStatus) + + const result = await service.getCollaboratorsByStatus( + exampleProblem.id, + CollaboratorStatus.Active + ) + + expect(result).to.deep.equal([ + { + id: exampleViewerUser.id, + username: exampleViewerUser.username, + email: exampleViewerUser.email, + role: exampleViewerCollaborator.role + }, + { + id: exampleEditorUser.id, + username: exampleEditorUser.username, + email: exampleEditorUser.email, + role: exampleEditorCollaborator.role + } + ]) + }) + + it('returns pending collaborators', async () => { + db.polygonCollaborator.findMany.resolves([ + { + role: examplePendingCollaborator.role, + user: { + id: examplePendingUser.id, + username: examplePendingUser.username, + email: examplePendingUser.email + } + } + ]) + + const result = await service.getCollaboratorsByStatus( + exampleProblem.id, + CollaboratorStatus.Pending + ) + + expect(result).to.deep.equal([ + { + id: examplePendingUser.id, + username: examplePendingUser.username, + email: examplePendingUser.email, + role: examplePendingCollaborator.role + } + ]) + }) + }) + describe('updateCollaboratorRole', () => { + it('owner updates collaborator role', async () => { + db.polygonProblem.findUnique.resolves({ + createdById: exampleProblem.createdById + }) + db.polygonCollaborator.findFirst.resolves({ + id: exampleViewerCollaborator.id, + status: exampleViewerCollaborator.status + }) + const updatedCollaborator = { + ...exampleViewerCollaborator, + role: CollaboratorRole.Editor + } + db.polygonCollaborator.update.resolves(updatedCollaborator) + + const result = await service.updateCollaboratorRole( + exampleOwner.id, + exampleProblem.id, + { userId: exampleViewerUser.id, role: CollaboratorRole.Editor } + ) + + expect(result).to.deep.equal(updatedCollaborator) + }) + it('Editor cannot update collaborator role', async () => { + db.polygonProblem.findUnique.resolves({ + createdById: exampleProblem.createdById + }) + + await expect( + service.updateCollaboratorRole( + exampleEditorUser.id, + exampleProblem.id, + { userId: exampleViewerUser.id, role: CollaboratorRole.Editor } + ) + ).to.be.rejectedWith(ForbiddenAccessException) + }) + }) +}) From f27b65b37e8e8ff7b1f007200ee85f5fc490c1db Mon Sep 17 00:00:00 2001 From: juheon429 Date: Mon, 11 May 2026 15:58:53 +0000 Subject: [PATCH 04/12] feat(be): implement collaboration request --- .../collaborator/collaborator.resolver.ts | 15 +++++- .../collaborator/collaborator.service.ts | 48 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.resolver.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.resolver.ts index f1f03303e9..faaffff3fb 100644 --- a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.resolver.ts +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.resolver.ts @@ -1,5 +1,5 @@ import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql' -import { CollaboratorStatus } from '@prisma/client' +import { CollaboratorStatus, CollaboratorRole } from '@prisma/client' import { AuthenticatedRequest } from '@libs/auth' import { IDValidationPipe } from '@libs/pipe' import { CollaboratorService } from './collaborator.service' @@ -96,4 +96,17 @@ export class CollaboratorResolver { userId ) } + + @Mutation() + async requestCollaboration( + @Context('req') req: AuthenticatedRequest, + @Args('polygonId', { type: () => Int }, IDValidationPipe) polygonId: number, + @Args('role') role: CollaboratorRole + ) { + return await this.collaboratorService.requestCollaboration( + req.user.id, + polygonId, + role + ) + } } diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts index 0d295ff929..cd0a128578 100644 --- a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts @@ -313,4 +313,52 @@ export class CollaboratorService { where: { id: collaborator.id } }) } + + /** + * 사용자가 협업자가 되기 위해 요청합니다. + * -role: Viewer, Editor + * + * @param {number} userId 요청자의 id + * @param {number} polygonId 해당 문제의 polygonId + * @param {CollaboratorRole} role 요청하는 역할 + * @returns {polygonCollaborator} 협업자 정보 + * @throws {EntityNotExistException} 아래와 같은 경우 발생합니다. + * -해당 polygonId에 해당하는 문제가 존재하지 않는 경우 + * @throws {DuplicateFoundException} 아래와 같은 경우 발생합니다. + * - 문제 소유자가 요청을 하는 경우 + * - 이미 등록된 협업자가 요청하는 경우 + * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. + * - Owner role로 요청을 하는 경우 + */ + async requestCollaboration( + userId: number, + polygonId: number, + role: CollaboratorRole + ) { + const problem = await this.prisma.polygonProblem.findUnique({ + where: { id: polygonId }, + select: { createdById: true } + }) + if (!problem) throw new EntityNotExistException('PolygonProblem not found') + if (userId === problem.createdById) { + throw new DuplicateFoundException('is owner') + } + if (role === CollaboratorRole.Owner) { + throw new ForbiddenAccessException('Cannot assign Owner role') + } + const existing = await this.prisma.polygonCollaborator.findFirst({ + where: { problemId: polygonId, userId }, + select: { id: true } + }) + if (existing) throw new DuplicateFoundException('Collaborator') + + return await this.prisma.polygonCollaborator.create({ + data: { + problemId: polygonId, + userId, + role, + status: CollaboratorStatus.Pending + } + }) + } } From cd11931b4033db107734f617a50c7046e25b2f53 Mon Sep 17 00:00:00 2001 From: juheon429 Date: Mon, 11 May 2026 16:15:44 +0000 Subject: [PATCH 05/12] feat(be): block owner role assign --- .../polygon/collaborator/collaborator.service.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts index cd0a128578..054a1173fb 100644 --- a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts @@ -32,6 +32,8 @@ export class CollaboratorService { * @throws {DuplicateFoundException} 아래와 같은 경우 발생합니다. * -이미 초대된 협업자를 초대한 경우 * -문제 소유자를 초대한 경우 + * @throws {UnprocessableDataException} 아래와 같은 경우 발생합니다. + * - Owner role로 초대을 하는 경우 */ async inviteCollaborator( inviterId: number, @@ -39,6 +41,11 @@ export class CollaboratorService { input: CollaboratorInput ) { const { userEmail, role } = input + + if (role === CollaboratorRole.Owner) { + throw new UnprocessableDataException('Cannot assign Owner role') + } + const user = await this.prisma.user.findUnique({ where: { email: userEmail }, select: { id: true } @@ -243,6 +250,8 @@ export class CollaboratorService { * -해당 userId에 해당하는 협업자가 존재하지 않는 경우 * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. * -invitorId가 해당 문제의 소유자가 아닌 경우 + * @throws {UnprocessableDataException} 아래와 같은 경우 발생합니다. + * - Owner role로 변경을 하는 경우 */ async updateCollaboratorRole( inviterId: number, @@ -250,6 +259,9 @@ export class CollaboratorService { input: CollaboratorUpdateInput ) { const { userId, role } = input + if (role === CollaboratorRole.Owner) { + throw new UnprocessableDataException('Cannot assign Owner role') + } const problem = await this.prisma.polygonProblem.findUnique({ where: { id: polygonId }, select: { createdById: true } @@ -327,7 +339,7 @@ export class CollaboratorService { * @throws {DuplicateFoundException} 아래와 같은 경우 발생합니다. * - 문제 소유자가 요청을 하는 경우 * - 이미 등록된 협업자가 요청하는 경우 - * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. + * @throws {UnprocessableDataException} 아래와 같은 경우 발생합니다. * - Owner role로 요청을 하는 경우 */ async requestCollaboration( @@ -344,7 +356,7 @@ export class CollaboratorService { throw new DuplicateFoundException('is owner') } if (role === CollaboratorRole.Owner) { - throw new ForbiddenAccessException('Cannot assign Owner role') + throw new UnprocessableDataException('Cannot assign Owner role') } const existing = await this.prisma.polygonCollaborator.findFirst({ where: { problemId: polygonId, userId }, From a062957b5dd8320297afce0facc4c376f089f94f Mon Sep 17 00:00:00 2001 From: juheon429 Date: Mon, 11 May 2026 16:31:05 +0000 Subject: [PATCH 06/12] feat(be): add permission check to getCollaboratorsByStatus --- .../collaborator/collaborator.resolver.ts | 4 ++++ .../collaborator/collaborator.service.ts | 19 +++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.resolver.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.resolver.ts index faaffff3fb..8c1e02466a 100644 --- a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.resolver.ts +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.resolver.ts @@ -27,9 +27,11 @@ export class CollaboratorResolver { @Query() async getActiveCollaborator( + @Context('req') req: AuthenticatedRequest, @Args('polygonId', { type: () => Int }, IDValidationPipe) polygonId: number ) { return await this.collaboratorService.getCollaboratorsByStatus( + req.user.id, polygonId, CollaboratorStatus.Active ) @@ -37,9 +39,11 @@ export class CollaboratorResolver { @Query() async getPendingCollaborator( + @Context('req') req: AuthenticatedRequest, @Args('polygonId', { type: () => Int }, IDValidationPipe) polygonId: number ) { return await this.collaboratorService.getCollaboratorsByStatus( + req.user.id, polygonId, CollaboratorStatus.Pending ) diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts index 054a1173fb..3113b502d3 100644 --- a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts @@ -109,15 +109,30 @@ export class CollaboratorService { * Status에 따른 협업자 목록을 반환합니다. * * status : Pending(수락 대기 중), Active(활성화 됨) - * + * @param {number} userId 문제 소유자의 id * @param {number} polygonId 생성 문제의 id * @param {CollaboratorStatus} status 협업자의 상태 - * @returns {olygonCollaborator[]} 협업자 목록 + * @returns {PolygonCollaborator[]} 협업자 목록 + * @throws {EntityNotExistException} 아래와 같은 경우 발생합니다. + * - 해당 polygonId에 해당하는 문제가 존재하지 않는 경우 + * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. + * - 협업자 목록 요청자가 문제 소유자가 아닌 경우 */ async getCollaboratorsByStatus( + userId: number, polygonId: number, status: CollaboratorStatus ) { + const problem = await this.prisma.polygonProblem.findUnique({ + where: { id: polygonId }, + select: { createdById: true } + }) + if (!problem) throw new EntityNotExistException('PolygonProblem not found') + + if (problem.createdById !== userId) { + throw new ForbiddenAccessException('No permission to view collaborators') + } + const collaborators = await this.prisma.polygonCollaborator.findMany({ where: { problemId: polygonId, From 7e574ebe9cf784d88562fcaded69a951f002b8aa Mon Sep 17 00:00:00 2001 From: juheon429 Date: Mon, 11 May 2026 16:47:46 +0000 Subject: [PATCH 07/12] feat(be): change permission check to unit test --- .../src/polygon/collaborator/collaborator.service.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.spec.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.spec.ts index f16cb1b48c..7c08f0aa38 100644 --- a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.spec.ts +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.spec.ts @@ -168,9 +168,13 @@ describe('CollaboratorService', () => { describe('getCollaboratorsByStatus', () => { it('return active collaborators', async () => { + db.polygonProblem.findUnique.resolves({ + createdById: exampleProblem.createdById + }) db.polygonCollaborator.findMany.resolves(exampleCollaboratorListByStatus) const result = await service.getCollaboratorsByStatus( + exampleOwner.id, exampleProblem.id, CollaboratorStatus.Active ) @@ -192,6 +196,9 @@ describe('CollaboratorService', () => { }) it('returns pending collaborators', async () => { + db.polygonProblem.findUnique.resolves({ + createdById: exampleProblem.createdById + }) db.polygonCollaborator.findMany.resolves([ { role: examplePendingCollaborator.role, @@ -204,6 +211,7 @@ describe('CollaboratorService', () => { ]) const result = await service.getCollaboratorsByStatus( + exampleOwner.id, exampleProblem.id, CollaboratorStatus.Pending ) From a4e554b1d964514b4efff717ff245379b98439f4 Mon Sep 17 00:00:00 2001 From: juheon429 Date: Thu, 21 May 2026 15:50:00 +0000 Subject: [PATCH 08/12] feat(be): change exception handling in inviteCollavorator --- .../collaborator/collaborator.service.ts | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts index 3113b502d3..f7e2489df0 100644 --- a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common' -import { CollaboratorRole, CollaboratorStatus } from '@prisma/client' +import { CollaboratorRole, CollaboratorStatus, Prisma } from '@prisma/client' import { EntityNotExistException, ForbiddenAccessException, @@ -88,21 +88,34 @@ export class CollaboratorService { }, select: { id: true } }) - if (existing || userId === problem.createdById) { - throw new DuplicateFoundException('Collaborator') + if (existing) { + throw new DuplicateFoundException('invited existing Collaborator') + } + if (userId === problem.createdById) { + throw new DuplicateFoundException('invited owner to collaborator') } const status = isOwner ? CollaboratorStatus.Active : CollaboratorStatus.Pending - return await this.prisma.polygonCollaborator.create({ - data: { - problemId: polygonId, - userId, - role, - status + try { + return await this.prisma.polygonCollaborator.create({ + data: { + problemId: polygonId, + userId, + role, + status + } + }) + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + throw new DuplicateFoundException('Collaborator is already invited') } - }) + throw error + } } /** From f608ee9ea1349885547d1fa40417f5bfe8bf9b58 Mon Sep 17 00:00:00 2001 From: juheon429 Date: Thu, 21 May 2026 15:55:43 +0000 Subject: [PATCH 09/12] feat(be): change exception handling in requestCollaboration --- .../collaborator/collaborator.service.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts index f7e2489df0..b59d81f945 100644 --- a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts @@ -391,14 +391,23 @@ export class CollaboratorService { select: { id: true } }) if (existing) throw new DuplicateFoundException('Collaborator') - - return await this.prisma.polygonCollaborator.create({ - data: { - problemId: polygonId, - userId, - role, - status: CollaboratorStatus.Pending + try { + return await this.prisma.polygonCollaborator.create({ + data: { + problemId: polygonId, + userId, + role, + status: CollaboratorStatus.Pending + } + }) + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + throw new DuplicateFoundException('Collaborator is already requested') } - }) + throw error + } } } From 77e8df6e5d5aedbc43213a7e96ad76d429bf4184 Mon Sep 17 00:00:00 2001 From: juheon429 Date: Tue, 26 May 2026 09:50:17 +0000 Subject: [PATCH 10/12] feat(be): add additional condition to removeCollaborator --- .../admin/src/polygon/collaborator/collaborator.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts index b59d81f945..65f6fe59bd 100644 --- a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts @@ -343,7 +343,11 @@ export class CollaboratorService { } const collaborator = await this.prisma.polygonCollaborator.findFirst({ - where: { problemId: polygonId, userId }, + where: { + problemId: polygonId, + userId, + status: CollaboratorStatus.Active + }, select: { id: true } }) if (!collaborator) { From 7a450eb335e1518f392f2df0e3fadd4d2c5bdba2 Mon Sep 17 00:00:00 2001 From: juheon429 Date: Tue, 26 May 2026 10:18:42 +0000 Subject: [PATCH 11/12] chore(be): rename Viewer to Reviewer in CollaboratorRole --- .../collaborator/collaborator.service.spec.ts | 8 ++++---- .../polygon/collaborator/collaborator.service.ts | 4 ++-- .../migration.sql | 14 ++++++++++++++ apps/backend/prisma/schema.prisma | 2 +- 4 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260526100450_rename_viewer_to_reviewer/migration.sql diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.spec.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.spec.ts index 7c08f0aa38..09ad2acce5 100644 --- a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.spec.ts +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.spec.ts @@ -40,7 +40,7 @@ const exampleViewerCollaborator = { id: 1, problemId: 10, userId: exampleViewerUser.id, - role: CollaboratorRole.Viewer, + role: CollaboratorRole.Reviewer, status: CollaboratorStatus.Active } @@ -56,7 +56,7 @@ const examplePendingCollaborator = { id: 3, problemId: 10, userId: examplePendingUser.id, - role: CollaboratorRole.Viewer, + role: CollaboratorRole.Reviewer, status: CollaboratorStatus.Pending } @@ -144,7 +144,7 @@ describe('CollaboratorService', () => { exampleProblem.id, { userEmail: exampleUser.email, - role: CollaboratorRole.Viewer + role: CollaboratorRole.Reviewer } ) expect(result).to.deep.equal(exampleViewerCollaborator) @@ -160,7 +160,7 @@ describe('CollaboratorService', () => { await expect( service.inviteCollaborator(exampleViewerUser.id, exampleProblem.id, { userEmail: exampleUser.email, - role: CollaboratorRole.Viewer + role: CollaboratorRole.Reviewer }) ).to.be.rejectedWith(ForbiddenAccessException) }) diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts index 65f6fe59bd..cec19b6b6a 100644 --- a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts @@ -267,7 +267,7 @@ export class CollaboratorService { /** * 협업자의 role을 변경합니다. * - * role : Viewer, Editor로만 변경 + * role : Reviewer, Editor로만 변경 * * @param {number} inviterId 초대자의 id * @param {number} polygonId 생성 문제의 id @@ -360,7 +360,7 @@ export class CollaboratorService { /** * 사용자가 협업자가 되기 위해 요청합니다. - * -role: Viewer, Editor + * -role: Reviewer, Editor * * @param {number} userId 요청자의 id * @param {number} polygonId 해당 문제의 polygonId diff --git a/apps/backend/prisma/migrations/20260526100450_rename_viewer_to_reviewer/migration.sql b/apps/backend/prisma/migrations/20260526100450_rename_viewer_to_reviewer/migration.sql new file mode 100644 index 0000000000..fb28fd1098 --- /dev/null +++ b/apps/backend/prisma/migrations/20260526100450_rename_viewer_to_reviewer/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - The values [Viewer] on the enum `CollaboratorRole` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "public"."CollaboratorRole_new" AS ENUM ('Owner', 'Editor', 'Reviewer'); +ALTER TABLE "public"."polygon_collaborator" ALTER COLUMN "role" TYPE "public"."CollaboratorRole_new" USING ("role"::text::"public"."CollaboratorRole_new"); +ALTER TYPE "public"."CollaboratorRole" RENAME TO "CollaboratorRole_old"; +ALTER TYPE "public"."CollaboratorRole_new" RENAME TO "CollaboratorRole"; +DROP TYPE "public"."CollaboratorRole_old"; +COMMIT; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 56700392f2..5154d84365 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1048,7 +1048,7 @@ enum TestFileType { enum CollaboratorRole { Owner Editor - Viewer + Reviewer } enum CollaboratorStatus { From 01bf317b0528688f43f30876b7cb4a30aabc80d5 Mon Sep 17 00:00:00 2001 From: juheon429 Date: Tue, 26 May 2026 10:34:38 +0000 Subject: [PATCH 12/12] feat(be): add exception handling in updateCollaboratorRole --- .../admin/src/polygon/collaborator/collaborator.service.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts index cec19b6b6a..57a6b1f372 100644 --- a/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts +++ b/apps/backend/apps/admin/src/polygon/collaborator/collaborator.service.ts @@ -280,6 +280,7 @@ export class CollaboratorService { * -invitorId가 해당 문제의 소유자가 아닌 경우 * @throws {UnprocessableDataException} 아래와 같은 경우 발생합니다. * - Owner role로 변경을 하는 경우 + * - 변경하려는 협업자가 active하지 않는 경우 */ async updateCollaboratorRole( inviterId: number, @@ -307,6 +308,11 @@ export class CollaboratorService { if (!collaborator) { throw new EntityNotExistException('Collaborator not found') } + + if (collaborator.status !== CollaboratorStatus.Active) { + throw new UnprocessableDataException('Collaborator is not active') + } + return await this.prisma.polygonCollaborator.update({ where: { id: collaborator.id }, data: { role }