From e028449312fb05876d83a00e3e52adb7e2315ed6 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 20 Jun 2026 19:43:52 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=8A=B8=C2=B7=EB=A9=A4=EB=B2=84=20=ED=99=9C?= =?UTF-8?q?=EB=8F=99/=EC=95=8C=EB=A6=BC=20=EB=B0=B0=EC=84=A0=20(Phase=201?= =?UTF-8?q?=20=EB=A7=88=EB=AC=B4=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProjectService: 프로젝트별 연동 저장소 수·업무 완료/전체 수 집계 (Repo/Task 엔티티 forFeature 등록, 그룹 쿼리로 N+1 회피, findOne 멤버 로딩) - ProjectMemberService: 멤버 목록 taskCount(담당 업무 수) 집계 - ProjectMemberService: 초대/역할변경/제거 시 활동 적재 + 대상자 알림 발송 (ActivityModule/NotificationModule 주입, member.* 페이로드 projectId/projectName/targetName) - TaskService: 다른 모듈로 이관되어 미사용된 countsByProject/assigneeCounts 제거 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../modules/project/project-member.service.ts | 78 +++++++++++++++++- backend/src/modules/project/project.module.ts | 11 ++- .../src/modules/project/project.service.ts | 80 ++++++++++++++++--- backend/src/modules/task/task.service.ts | 40 ---------- 4 files changed, 155 insertions(+), 54 deletions(-) diff --git a/backend/src/modules/project/project-member.service.ts b/backend/src/modules/project/project-member.service.ts index 3b4d921..be780e6 100644 --- a/backend/src/modules/project/project-member.service.ts +++ b/backend/src/modules/project/project-member.service.ts @@ -18,6 +18,9 @@ import { ProjectMember, type ProjectRoleType, } from './entities/project-member.entity'; +import { Task } from '../task/entities/task.entity'; +import { ActivityService } from '../activity/activity.service'; +import { NotificationService } from '../notification/notification.service'; import { InviteProjectMemberDto } from './dto/invite-member.dto'; import { UpdateProjectMemberDto } from './dto/update-member.dto'; @@ -25,13 +28,12 @@ import { UpdateProjectMemberDto } from './dto/update-member.dto'; export interface ProjectMemberResponse { user: PublicUser; email: string; - taskCount: number; // TODO(Task 이관): 실제 담당 업무 수 집계 + taskCount: number; // 프로젝트 내 담당 업무 수 roleType: ProjectRoleType; owner: boolean; } -// 프로젝트 멤버 관리 비즈니스 로직 -// TODO(Activity/Notification 이관): 초대/역할변경/제거 시 활동 적재·알림 발송 배선 +// 프로젝트 멤버 관리 비즈니스 로직 — 초대/역할변경/제거 시 활동 적재·알림 발송 @Injectable() export class ProjectMemberService { constructor( @@ -39,7 +41,11 @@ export class ProjectMemberService { private readonly projectRepo: Repository, @InjectRepository(ProjectMember) private readonly memberRepo: Repository, + @InjectRepository(Task) + private readonly taskRepo: Repository, private readonly userService: UserService, + private readonly activity: ActivityService, + private readonly notification: NotificationService, ) {} // 멤버 목록 — 인증 사용자면 열람. 쓰기는 admin 가드. @@ -57,8 +63,9 @@ export class ProjectMemberService { skip: pg.skip, take: pg.take, }); + const counts = await this.assigneeCounts(project.id); return paginated( - members.map((m) => this.toResponse(m)), + members.map((m) => this.toResponse(m, counts.get(m.user.id) ?? 0)), total, pg, ); @@ -101,6 +108,20 @@ export class ProjectMemberService { }); const saved = await this.memberRepo.save(member); saved.user = user; + + // 활동 적재 + 초대 대상에게 알림 + await this.activity.record({ + projectId: project.id, + actorId: actingUserId, + type: 'member.invited', + payload: { targetName: user.name }, + }); + await this.notification.notify({ + recipientIds: [user.id], + actorId: actingUserId, + type: 'member.invited', + payload: { projectId: project.id, projectName: project.name }, + }); return this.toResponse(saved); } @@ -123,6 +144,24 @@ export class ProjectMemberService { } if (dto.roleType !== undefined) member.roleType = dto.roleType; await this.memberRepo.save(member); + + // 활동 적재 + 역할 변경 대상에게 알림 + await this.activity.record({ + projectId: project.id, + actorId: actingUserId, + type: 'member.role_changed', + payload: { targetName: member.user.name, roleType: member.roleType }, + }); + await this.notification.notify({ + recipientIds: [targetUserId], + actorId: actingUserId, + type: 'member.role_changed', + payload: { + projectId: project.id, + projectName: project.name, + roleType: member.roleType, + }, + }); return this.toResponse(member); } @@ -143,10 +182,41 @@ export class ProjectMemberService { ); } await this.memberRepo.remove(member); + + // 활동 적재 + 제외 대상에게 알림(remove 직후 메모리의 user 사용) + await this.activity.record({ + projectId: project.id, + actorId: actingUserId, + type: 'member.removed', + payload: { targetName: member.user.name }, + }); + await this.notification.notify({ + recipientIds: [targetUserId], + actorId: actingUserId, + type: 'member.removed', + payload: { projectId: project.id, projectName: project.name }, + }); } // --- 내부 헬퍼 --- + // 프로젝트 내 사용자별 담당 업무 수 — 멤버 목록 taskCount 집계용 + private async assigneeCounts( + projectId: string, + ): Promise> { + const rows = await this.taskRepo + .createQueryBuilder('task') + .innerJoin('task_assignees', 'ta', 'ta.task_id = task.id') + .select('ta.user_id', 'userId') + .addSelect('COUNT(*)', 'count') + .where('task.project_id = :projectId', { projectId }) + .groupBy('ta.user_id') + .getRawMany<{ userId: string; count: string }>(); + const map = new Map(); + for (const r of rows) map.set(r.userId, Number(r.count)); + return map; + } + private async getProjectOrThrow(projectId: string): Promise { const project = await this.projectRepo.findOne({ where: { id: projectId }, diff --git a/backend/src/modules/project/project.module.ts b/backend/src/modules/project/project.module.ts index 01d1f3f..f3cc238 100644 --- a/backend/src/modules/project/project.module.ts +++ b/backend/src/modules/project/project.module.ts @@ -3,6 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { UserModule } from '../user/user.module'; import { Project } from './entities/project.entity'; import { ProjectMember } from './entities/project-member.entity'; +import { Repo } from '../repo/entities/repo.entity'; +import { Task } from '../task/entities/task.entity'; +import { ActivityModule } from '../activity/activity.module'; +import { NotificationModule } from '../notification/notification.module'; import { ProjectService } from './project.service'; import { ProjectMemberService } from './project-member.service'; import { ProjectController } from './project.controller'; @@ -11,7 +15,12 @@ import { ProjectMemberController } from './project-member.controller'; // 프로젝트 모듈 — 작업의 기본 단위. 저장소(Repo)·업무(Task)·활동(Activity)이 프로젝트에 연동된다. // ProjectService 의 권한 헬퍼(assertProjectMember/Admin)를 다른 모듈이 주입해 사용한다. @Module({ - imports: [TypeOrmModule.forFeature([Project, ProjectMember]), UserModule], + imports: [ + TypeOrmModule.forFeature([Project, ProjectMember, Repo, Task]), + UserModule, + ActivityModule, + NotificationModule, + ], controllers: [ProjectController, ProjectMemberController], providers: [ProjectService, ProjectMemberService], exports: [ProjectService], diff --git a/backend/src/modules/project/project.service.ts b/backend/src/modules/project/project.service.ts index e61e71f..46c8ed5 100644 --- a/backend/src/modules/project/project.service.ts +++ b/backend/src/modules/project/project.service.ts @@ -15,6 +15,8 @@ import { } from '../../common/pagination/pagination'; import { Project, type ProjectVisibility } from './entities/project.entity'; import { ProjectMember } from './entities/project-member.entity'; +import { Repo } from '../repo/entities/repo.entity'; +import { Task } from '../task/entities/task.entity'; import { CreateProjectDto } from './dto/create-project.dto'; import { UpdateProjectDto } from './dto/update-project.dto'; @@ -49,6 +51,10 @@ export class ProjectService implements OnApplicationBootstrap { private readonly projectRepo: Repository, @InjectRepository(ProjectMember) private readonly memberRepo: Repository, + @InjectRepository(Repo) + private readonly repoRepo: Repository, + @InjectRepository(Task) + private readonly taskRepo: Repository, private readonly userService: UserService, ) {} @@ -69,8 +75,13 @@ export class ProjectService implements OnApplicationBootstrap { skip: pg.skip, take: pg.take, }); + const ids = projects.map((p) => p.id); + const [repoMap, taskMap] = await Promise.all([ + this.repoCountsByProject(ids), + this.taskCountsByProject(ids), + ]); return paginated( - projects.map((p) => this.toResponse(p)), + projects.map((p) => this.toResponse(p, repoMap, taskMap)), total, pg, ); @@ -78,11 +89,21 @@ export class ProjectService implements OnApplicationBootstrap { // 프로젝트 단건 — 인증 사용자면 열람 가능. 현재 사용자 권한(canManage/isOwner) 포함. async findOne(projectId: string, userId: string): Promise { - const project = await this.getProjectOrThrow(projectId); + const project = await this.projectRepo.findOne({ + where: { id: projectId }, + relations: ['members', 'members.user'], + }); + if (!project) { + throw new NotFoundException(); + } const membership = await this.memberRepo.findOne({ where: { project: { id: project.id }, user: { id: userId } }, }); - const base = this.toResponse(project); + const [repoMap, taskMap] = await Promise.all([ + this.repoCountsByProject([project.id]), + this.taskCountsByProject([project.id]), + ]); + const base = this.toResponse(project, repoMap, taskMap); return { ...base, canManage: membership?.roleType === 'admin', @@ -205,10 +226,51 @@ export class ProjectService implements OnApplicationBootstrap { } } - // 엔티티 → 응답 매핑. - // TODO(Task 이관): repoCount/doneCount/totalCount 를 RepoService/TaskService 로 집계(필요 시 async 전환). - private toResponse(project: Project): ProjectResponse { + // 프로젝트별 연동 저장소 수 — 목록/단건 진행 표시용 + private async repoCountsByProject( + ids: string[], + ): Promise> { + const map = new Map(); + if (ids.length === 0) return map; + const rows = await this.repoRepo + .createQueryBuilder('repo') + .select('repo.project_id', 'projectId') + .addSelect('COUNT(*)', 'count') + .where('repo.project_id IN (:...ids)', { ids }) + .groupBy('repo.project_id') + .getRawMany<{ projectId: string; count: string }>(); + for (const r of rows) map.set(r.projectId, Number(r.count)); + return map; + } + + // 프로젝트별 업무 완료/전체 수 — 진행률 집계용 + private async taskCountsByProject( + ids: string[], + ): Promise> { + const map = new Map(); + if (ids.length === 0) return map; + const rows = await this.taskRepo + .createQueryBuilder('task') + .select('task.project_id', 'projectId') + .addSelect('COUNT(*)', 'total') + .addSelect("COUNT(*) FILTER (WHERE task.status = 'done')", 'done') + .where('task.project_id IN (:...ids)', { ids }) + .groupBy('task.project_id') + .getRawMany<{ projectId: string; total: string; done: string }>(); + for (const r of rows) { + map.set(r.projectId, { done: Number(r.done), total: Number(r.total) }); + } + return map; + } + + // 엔티티 → 응답 매핑. 카운트 맵에서 프로젝트별 집계를 주입한다. + private toResponse( + project: Project, + repoMap: Map, + taskMap: Map, + ): ProjectResponse { const members = project.members ?? []; + const counts = taskMap.get(project.id); return { id: project.id, name: project.name, @@ -219,9 +281,9 @@ export class ProjectService implements OnApplicationBootstrap { .filter((m) => m.user) .map((m) => UserService.toPublic(m.user)), memberCount: members.length, - repoCount: 0, - doneCount: 0, - totalCount: 0, + repoCount: repoMap.get(project.id) ?? 0, + doneCount: counts?.done ?? 0, + totalCount: counts?.total ?? 0, updatedAt: project.updatedAt.toISOString(), }; } diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index 4f597ef..3461d3a 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -978,46 +978,6 @@ export class TaskService { }; } - // --- 집계(다른 모듈에서 사용) --- - - // 저장소별 업무 완료/전체 수 — RepoService 진행률 집계용 - async countsByProject( - projectIds: string[], - ): Promise> { - const result = new Map(); - if (projectIds.length === 0) return result; - const rows = await this.taskRepo - .createQueryBuilder('task') - .select('task.project_id', 'projectId') - .addSelect('COUNT(*)', 'total') - .addSelect("COUNT(*) FILTER (WHERE task.status = 'done')", 'done') - .where('task.project_id IN (:...projectIds)', { projectIds }) - .groupBy('task.project_id') - .getRawMany<{ projectId: string; total: string; done: string }>(); - for (const r of rows) { - result.set(r.projectId, { - done: Number(r.done), - total: Number(r.total), - }); - } - return result; - } - - // 저장소 내 사용자별 담당 업무 수 — MemberService taskCount 집계용 - async assigneeCounts(projectId: string): Promise> { - const rows = await this.taskRepo - .createQueryBuilder('task') - .innerJoin('task_assignees', 'ta', 'ta.task_id = task.id') - .select('ta.user_id', 'userId') - .addSelect('COUNT(*)', 'count') - .where('task.project_id = :projectId', { projectId }) - .groupBy('ta.user_id') - .getRawMany<{ userId: string; count: string }>(); - const map = new Map(); - for (const r of rows) map.set(r.userId, Number(r.count)); - return map; - } - // --- 내부 헬퍼 --- // 상태 전이 1건 적용 — 전이 규칙 + 역할 검증 후 저장(부수효과 포함)