Skip to content

Commit ce1d9ce

Browse files
committed
feat: 프로젝트 이름, 주제 수정 API 구현
- 프로젝트 이름, 주제 수정 레포지토리, 서비스 구현 - 리더가 아닌 인원이 수정시 웹소켓 연결을 끊도록 구현 - 프로젝트 이름, 주제 수정 시 랜딩, 설정 페이지에 있는 회원들에게 알림을 주도록 구현 - 프로젝트 이름, 주제 수정 DTO 추가 - 프로젝트 이름, 주제 수정 E2E테스트 추가
1 parent 5e18147 commit ce1d9ce

File tree

8 files changed

+254
-4
lines changed

8 files changed

+254
-4
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
class projectInfo {
2+
title: string;
3+
subject: string;
4+
}
5+
6+
export class ProjectInfoUpdateNotifyDto {
7+
domain: string;
8+
action: string;
9+
content: projectInfo;
10+
11+
static of(title: string, subject: string) {
12+
const dto = new ProjectInfoUpdateNotifyDto();
13+
dto.domain = 'projectInfo';
14+
dto.action = 'update';
15+
dto.content = {
16+
title,
17+
subject,
18+
};
19+
return dto;
20+
}
21+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Type } from 'class-transformer';
2+
import {
3+
IsNotEmpty,
4+
IsString,
5+
Matches,
6+
MaxLength,
7+
ValidateNested,
8+
} from 'class-validator';
9+
10+
class ProjectInfo {
11+
@IsString()
12+
@IsNotEmpty()
13+
@MaxLength(256, { message: 'Title is too long' })
14+
title: string;
15+
16+
@IsString()
17+
@IsNotEmpty()
18+
@MaxLength(256, { message: 'Subject is too long' })
19+
subject: string;
20+
}
21+
22+
export class ProjectInfoUpdateRequestDto {
23+
@Matches(/^update$/)
24+
action: string;
25+
26+
@IsNotEmpty()
27+
@ValidateNested()
28+
@Type(() => ProjectInfo)
29+
content: ProjectInfo;
30+
}

backend/src/project/project.module.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Story } from './entity/story.entity';
2222
import { WsProjectStoryController } from './ws-controller/ws-project-story.controller';
2323
import { Task } from './entity/task.entity';
2424
import { WsProjectTaskController } from './ws-controller/ws-project-task.controller';
25+
import { WsProjectInfoController } from './ws-controller/ws-project-info.controller';
2526

2627
@Module({
2728
imports: [
@@ -33,8 +34,8 @@ import { WsProjectTaskController } from './ws-controller/ws-project-task.control
3334
Memo,
3435
Link,
3536
Epic,
36-
Story,
37-
Task
37+
Story,
38+
Task,
3839
]),
3940
],
4041
controllers: [ProjectController],
@@ -49,8 +50,9 @@ import { WsProjectTaskController } from './ws-controller/ws-project-task.control
4950
WsProjectLinkController,
5051
WsProjectController,
5152
WsProjectEpicController,
52-
WsProjectStoryController,
53-
WsProjectTaskController,
53+
WsProjectStoryController,
54+
WsProjectTaskController,
55+
WsProjectInfoController,
5456
],
5557
})
5658
export class ProjectModule {}

backend/src/project/project.repository.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,29 @@ export class ProjectRepository {
3737
return this.projectRepository.save(project);
3838
}
3939

40+
async updateProjectInfo(
41+
project: Project,
42+
title: string,
43+
subject: string,
44+
): Promise<boolean> {
45+
const updateData: Partial<Project> = {};
46+
47+
if (title !== undefined) {
48+
updateData.title = title;
49+
}
50+
if (subject !== undefined) {
51+
updateData.subject = subject;
52+
}
53+
54+
const result = await this.projectRepository.update(
55+
{
56+
id: project.id,
57+
},
58+
updateData,
59+
);
60+
return !!result.affected;
61+
}
62+
4063
addProjectMember(
4164
project: Project,
4265
member: Member,

backend/src/project/service/project.service.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ export class ProjectService {
2626
);
2727
}
2828

29+
async updateProjectInfo(
30+
project: Project,
31+
member: Member,
32+
title: string,
33+
subject: string,
34+
): Promise<boolean> {
35+
if (!(await this.isProjectLeader(project, member))) {
36+
throw new Error('Member is not the project leader');
37+
}
38+
return this.projectRepository.updateProjectInfo(project, title, subject);
39+
}
40+
2941
async getProjectList(member: Member): Promise<Project[]> {
3042
return await this.projectRepository.getProjectList(member);
3143
}

backend/src/project/websocket.gateway.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ClientSocket } from './type/ClientSocket.type';
2121
import { WsProjectEpicController } from './ws-controller/ws-project-epic.controller';
2222
import { WsProjectStoryController } from './ws-controller/ws-project-story.controller';
2323
import { WsProjectTaskController } from './ws-controller/ws-project-task.controller';
24+
import { WsProjectInfoController } from './ws-controller/ws-project-info.controller';
2425

2526
@WebSocketGateway({
2627
namespace: /project-\d+/,
@@ -40,6 +41,7 @@ export class ProjectWebsocketGateway
4041
private readonly wsProjectEpicController: WsProjectEpicController,
4142
private readonly wsProjectStoryController: WsProjectStoryController,
4243
private readonly wsProjectTaskController: WsProjectTaskController,
44+
private readonly wsProjectInfoController: WsProjectInfoController,
4345
) {
4446
this.namespaceMap = new Map();
4547
}
@@ -181,6 +183,16 @@ export class ProjectWebsocketGateway
181183
this.wsProjectController.joinSettingPage(client);
182184
}
183185

186+
@SubscribeMessage('projectInfo')
187+
async handleProjectInfoEvent(
188+
@ConnectedSocket() client: ClientSocket,
189+
@MessageBody() data: any,
190+
) {
191+
if (data.action === 'update') {
192+
this.wsProjectInfoController.updateProjectInfo(client, data);
193+
}
194+
}
195+
184196
notifyJoinToConnectedMembers(projectId: number, member: Member) {
185197
const projectNamespace = this.namespaceMap.get(projectId);
186198
if (!projectNamespace) return;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { ClientSocket } from '../type/ClientSocket.type';
3+
import { validate } from 'class-validator';
4+
import { plainToClass } from 'class-transformer';
5+
import { getRecursiveErrorMsgList } from '../util/validation.util';
6+
import { ProjectService } from '../service/project.service';
7+
import { ProjectInfoUpdateRequestDto } from '../dto/project-info/ProjectInfoUpdateRequest.dto';
8+
import { ProjectInfoUpdateNotifyDto } from '../dto/project-info/ProjectInfoUpdateNotify.dto';
9+
10+
@Injectable()
11+
export class WsProjectInfoController {
12+
constructor(private readonly projectService: ProjectService) {}
13+
async updateProjectInfo(client: ClientSocket, data: any) {
14+
const errors = await validate(
15+
plainToClass(ProjectInfoUpdateRequestDto, data),
16+
);
17+
if (errors.length > 0) {
18+
const errorList = getRecursiveErrorMsgList(errors);
19+
client.emit('error', { errorList });
20+
return;
21+
}
22+
try {
23+
const { content } = data as ProjectInfoUpdateRequestDto;
24+
const isUpdate = await this.projectService.updateProjectInfo(
25+
client.project,
26+
client.member,
27+
content.title,
28+
content.subject,
29+
);
30+
if (isUpdate) {
31+
const data = ProjectInfoUpdateNotifyDto.of(
32+
content.title,
33+
content.subject,
34+
);
35+
client.nsp.to('landing').emit('landing', data);
36+
client.nsp.to('setting').emit('setting', data);
37+
}
38+
} catch (e) {
39+
if (e.message === 'Member is not the project leader') {
40+
client.disconnect(true);
41+
} else throw e;
42+
}
43+
}
44+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Socket } from 'socket.io-client';
2+
import {
3+
app,
4+
appInit,
5+
connectServer,
6+
createMember,
7+
createProject,
8+
getProjectLinkId,
9+
joinProject,
10+
listenAppAndSetPortEnv,
11+
memberFixture,
12+
memberFixture2,
13+
projectPayload,
14+
} from 'test/setup';
15+
import { handleConnectErrorWithReject } from '../ws-common';
16+
17+
describe('WS projectInfo', () => {
18+
beforeEach(async () => {
19+
await app.close();
20+
await appInit();
21+
await listenAppAndSetPortEnv(app);
22+
});
23+
24+
describe('update projectInfo', () => {
25+
it('Should return project setting data when leader enters setting page', async () => {
26+
let socket1: Socket;
27+
let socket2: Socket;
28+
29+
await new Promise<void>(async (resolve, reject) => {
30+
const accessToken1 = (await createMember(memberFixture, app))
31+
.accessToken;
32+
const project = await createProject(accessToken1, projectPayload, app);
33+
const projectLinkId = await getProjectLinkId(accessToken1, project.id);
34+
35+
const accessToken2 = (await createMember(memberFixture2, app))
36+
.accessToken;
37+
await joinProject(accessToken2, projectLinkId);
38+
39+
socket1 = connectServer(project.id, accessToken1);
40+
handleConnectErrorWithReject(socket1, reject);
41+
await joinSettingPage(socket1);
42+
43+
socket2 = connectServer(project.id, accessToken2);
44+
handleConnectErrorWithReject(socket2, reject);
45+
await joinLandingPage(socket2);
46+
47+
const newTitle = '새로운 제목';
48+
const newSubject = '새로운 주제';
49+
socket1.emit('projectInfo', {
50+
action: 'update',
51+
content: { title: newTitle, subject: newSubject },
52+
});
53+
await Promise.all([
54+
expectUpdateProjectInfo(socket1, 'setting', newTitle, newSubject),
55+
expectUpdateProjectInfo(socket2, 'landing', newTitle, newSubject),
56+
]);
57+
resolve();
58+
}).finally(() => {
59+
socket1.close();
60+
socket2.close();
61+
});
62+
});
63+
64+
const joinSettingPage = (socket: Socket) => {
65+
return new Promise<void>((resolve) => {
66+
socket.emit('joinSetting');
67+
socket.once('setting', (data) => {
68+
const { action, domain } = data;
69+
if (domain === 'setting' && action === 'init') {
70+
resolve();
71+
}
72+
});
73+
});
74+
};
75+
76+
const joinLandingPage = (socket: Socket) => {
77+
return new Promise<void>((resolve) => {
78+
socket.emit('joinLanding');
79+
socket.once('landing', (data) => {
80+
const { action, domain } = data;
81+
if (domain === 'landing' && action === 'init') {
82+
resolve();
83+
}
84+
});
85+
});
86+
};
87+
88+
const expectUpdateProjectInfo = (
89+
socket: Socket,
90+
eventPage: string,
91+
title: string,
92+
subject: string,
93+
) => {
94+
return new Promise<void>((resolve) => {
95+
socket.on(eventPage, (data) => {
96+
const { action, domain, content } = data;
97+
if (domain === 'projectInfo' && action === 'update') {
98+
expect(content.title).toBe(title);
99+
expect(content.subject).toBe(subject);
100+
resolve();
101+
}
102+
});
103+
});
104+
};
105+
});
106+
});

0 commit comments

Comments
 (0)