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:
@@ -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 },
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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건 적용 — 전이 규칙 + 역할 검증 후 저장(부수효과 포함)
|
||||
|
||||
Reference in New Issue
Block a user