feat: 프로젝트 카운트·멤버 활동/알림 배선 (Phase 1 마무리)

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 19:43:52 +09:00
parent 7cd6676ef9
commit e028449312
4 changed files with 155 additions and 54 deletions
@@ -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<Project>,
@InjectRepository(ProjectMember)
private readonly memberRepo: Repository<ProjectMember>,
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
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<Map<string, number>> {
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<string, number>();
for (const r of rows) map.set(r.userId, Number(r.count));
return map;
}
private async getProjectOrThrow(projectId: string): Promise<Project> {
const project = await this.projectRepo.findOne({
where: { id: projectId },
+10 -1
View File
@@ -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],
+71 -9
View File
@@ -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<Project>,
@InjectRepository(ProjectMember)
private readonly memberRepo: Repository<ProjectMember>,
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
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<ProjectResponse> {
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<Map<string, number>> {
const map = new Map<string, number>();
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<Map<string, { done: number; total: number }>> {
const map = new Map<string, { done: number; total: number }>();
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<string, number>,
taskMap: Map<string, { done: number; total: number }>,
): 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(),
};
}
-40
View File
@@ -978,46 +978,6 @@ export class TaskService {
};
}
// --- 집계(다른 모듈에서 사용) ---
// 저장소별 업무 완료/전체 수 — RepoService 진행률 집계용
async countsByProject(
projectIds: string[],
): Promise<Map<string, { done: number; total: number }>> {
const result = new Map<string, { done: number; total: number }>();
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<Map<string, number>> {
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<string, number>();
for (const r of rows) map.set(r.userId, Number(r.count));
return map;
}
// --- 내부 헬퍼 ---
// 상태 전이 1건 적용 — 전이 규칙 + 역할 검증 후 저장(부수효과 포함)