Skip to content

Commit fe707f1

Browse files
authored
[#362] 프로젝트 대본 일괄 수정 및 전체 조회 API 추가 (#363)
* feat: 프로젝트 대본 bulk edit API 추가 (#362) * test: 스크립트 bulk edit 서비스 테스트 추가 (#362) * refactor: bulk edit invalid slideId 일괄 검증 및 병렬 처리 (#362)
1 parent ad5db3c commit fe707f1

7 files changed

Lines changed: 607 additions & 6 deletions

File tree

src/controllers/script.controller.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { scriptResponseDTO, scriptVersionResponseDTO } from "../dtos/script.dto.js";
22
import {
3+
processBulkEditProjectScripts,
4+
processGetProjectScripts,
35
processScriptGet,
46
processScriptRestore,
57
processScriptUpdate,
@@ -248,3 +250,132 @@ export const handleRestoreVersion = async (req, res, next) => {
248250
next(error);
249251
}
250252
};
253+
254+
/**
255+
* @swagger
256+
* /presentations/{projectId}/scripts:
257+
* get:
258+
* summary: 프로젝트 전체 대본 조회
259+
* description: 프로젝트의 슬라이드 순서대로 현재 대본 목록을 조회합니다.
260+
* tags: [Script]
261+
* security:
262+
* - bearerAuth: []
263+
* parameters:
264+
* - in: path
265+
* name: projectId
266+
* required: true
267+
* schema:
268+
* type: string
269+
* example: "123"
270+
* description: 대본을 가져올 프로젝트 ID
271+
* responses:
272+
* 200:
273+
* description: 프로젝트 대본 조회 성공
274+
* content:
275+
* application/json:
276+
* example:
277+
* resultType: "SUCCESS"
278+
* error: null
279+
* success:
280+
* message: "프로젝트 대본이 성공적으로 조회되었습니다."
281+
* projectId: "123"
282+
* scripts:
283+
* - slideId: "1"
284+
* scriptText: "첫 번째 슬라이드 대본"
285+
* - slideId: "2"
286+
* scriptText: ""
287+
*/
288+
export const handleGetProjectScripts = async (req, res, next) => {
289+
try {
290+
const { projectId } = req.params;
291+
const userId = req.user.id;
292+
const result = await processGetProjectScripts({ projectId, userId });
293+
294+
res.status(200).json({
295+
resultType: "SUCCESS",
296+
error: null,
297+
success: {
298+
message: "프로젝트 대본이 성공적으로 조회되었습니다.",
299+
...result,
300+
},
301+
});
302+
} catch (error) {
303+
next(error);
304+
}
305+
};
306+
307+
/**
308+
* @swagger
309+
* /presentations/{projectId}/scripts/bulk-edit:
310+
* patch:
311+
* summary: 프로젝트 대본 일괄 수정
312+
* description: 프로젝트에 속한 여러 슬라이드의 대본을 한 번에 저장합니다.
313+
* tags: [Script]
314+
* security:
315+
* - bearerAuth: []
316+
* parameters:
317+
* - in: path
318+
* name: projectId
319+
* required: true
320+
* schema:
321+
* type: string
322+
* example: "123"
323+
* description: 대본을 수정할 프로젝트 ID
324+
* requestBody:
325+
* required: true
326+
* content:
327+
* application/json:
328+
* schema:
329+
* type: object
330+
* required:
331+
* - scripts
332+
* properties:
333+
* scripts:
334+
* type: array
335+
* items:
336+
* type: object
337+
* required:
338+
* - slideId
339+
* - scriptText
340+
* properties:
341+
* slideId:
342+
* type: string
343+
* example: "1"
344+
* scriptText:
345+
* type: string
346+
* example: "수정된 대본입니다."
347+
* responses:
348+
* 200:
349+
* description: 프로젝트 대본 일괄 수정 성공
350+
* content:
351+
* application/json:
352+
* example:
353+
* resultType: "SUCCESS"
354+
* error: null
355+
* success:
356+
* message: "대본 일괄 수정이 완료되었습니다."
357+
* projectId: "123"
358+
* requestedSlideCount: 2
359+
* updatedSlideCount: 1
360+
* unchangedSlideCount: 1
361+
* updatedSlideIds: ["1"]
362+
*/
363+
export const handleBulkEditProjectScripts = async (req, res, next) => {
364+
try {
365+
const { projectId } = req.params;
366+
const userId = req.user.id;
367+
const { scripts } = req.body;
368+
const result = await processBulkEditProjectScripts({ projectId, userId, scripts });
369+
370+
res.status(200).json({
371+
resultType: "SUCCESS",
372+
error: null,
373+
success: {
374+
message: "대본 일괄 수정이 완료되었습니다.",
375+
...result,
376+
},
377+
});
378+
} catch (error) {
379+
next(error);
380+
}
381+
};

src/errors/script.error.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,21 @@ export class VersionNotFoundError extends BaseError {
1111
super("버전이 존재하지 않습니다.", 404, "SC002", data);
1212
}
1313
}
14+
15+
export class ScriptBulkEditPayloadError extends BaseError {
16+
constructor(data) {
17+
super("유효한 대본 목록이 필요합니다.", 400, "SC003", data);
18+
}
19+
}
20+
21+
export class ScriptBulkEditDuplicateSlideError extends BaseError {
22+
constructor(data) {
23+
super("중복된 슬라이드 ID가 포함되어 있습니다.", 400, "SC004", data);
24+
}
25+
}
26+
27+
export class ScriptBulkEditSlideNotFoundError extends BaseError {
28+
constructor(data) {
29+
super("프로젝트에 속하지 않은 슬라이드가 포함되어 있습니다.", 400, "SC005", data);
30+
}
31+
}

src/repositories/script.repository.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,35 @@ export const getScriptVersionList = async (slideId) => {
6969
});
7070
};
7171

72+
// 프로젝트 슬라이드/대본 목록 조회 (일괄 수정용)
73+
export const getProjectSlidesWithScripts = async (projectId, userId) => {
74+
return await prisma.project.findFirst({
75+
where: {
76+
id: BigInt(projectId),
77+
userId: BigInt(userId),
78+
isDeleted: false,
79+
},
80+
select: {
81+
id: true,
82+
slides: {
83+
where: {
84+
isDeleted: false,
85+
},
86+
select: {
87+
id: true,
88+
slideNum: true,
89+
script: {
90+
select: {
91+
scriptText: true,
92+
},
93+
},
94+
},
95+
orderBy: [{ slideNum: "asc" }, { id: "asc" }],
96+
},
97+
},
98+
});
99+
};
100+
72101
// 대본 버전 복원
73102
export const postScriptVersion = async (slideId, versionNumber) => {
74103
return await prisma.$transaction(async (tx) => {

src/routes/project.route.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import {
77
handleGetProjectName,
88
handleUpdateProjectName,
99
} from "../controllers/project.controller.js";
10+
import {
11+
handleBulkEditProjectScripts,
12+
handleGetProjectScripts,
13+
} from "../controllers/script.controller.js";
1014
import {
1115
handleGetRecentComments,
1216
handleGetSlideAnalytics,
@@ -22,6 +26,12 @@ router.post("/", isLogin, handleCreateProject);
2226
// 프로젝트 목록 조회/검색
2327
router.get("/", isLogin, handleGetProjectList);
2428

29+
// 프로젝트 전체 대본 조회
30+
router.get("/:projectId/scripts", isLogin, handleGetProjectScripts);
31+
32+
// 프로젝트 대본 일괄 수정
33+
router.patch("/:projectId/scripts/bulk-edit", isLogin, handleBulkEditProjectScripts);
34+
2535
// 프로젝트 이름 업데이트
2636
router.patch("/:projectId", isLogin, handleUpdateProjectName);
2737

src/services/script.service.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import {
2+
getProjectSlidesWithScripts,
23
getScriptText,
34
getScriptVersionList,
45
postScriptVersion,
56
updateScriptText,
67
} from "../repositories/script.repository.js";
8+
import { ProjectNotFoundError } from "../errors/project.error.js";
9+
import {
10+
ScriptBulkEditDuplicateSlideError,
11+
ScriptBulkEditPayloadError,
12+
ScriptBulkEditSlideNotFoundError,
13+
} from "../errors/script.error.js";
714

815
export const processScriptUpdate = async (slideId, text) => {
916
try {
@@ -76,3 +83,100 @@ export const processScriptRestore = async (id, versionNumber) => {
7683
throw error;
7784
}
7885
};
86+
87+
const normalizeBulkEditScripts = (scripts) => {
88+
if (!Array.isArray(scripts) || scripts.length < 1) {
89+
throw new ScriptBulkEditPayloadError({ scripts });
90+
}
91+
92+
const normalized = [];
93+
const usedSlideIds = new Set();
94+
95+
for (let i = 0; i < scripts.length; i++) {
96+
const item = scripts[i];
97+
if (!item || typeof item !== "object") {
98+
throw new ScriptBulkEditPayloadError({ index: i, item });
99+
}
100+
101+
const slideId = item.slideId != null ? String(item.slideId) : "";
102+
if (!slideId || !/^\d+$/.test(slideId)) {
103+
throw new ScriptBulkEditPayloadError({ index: i, slideId: item.slideId });
104+
}
105+
106+
if (usedSlideIds.has(slideId)) {
107+
throw new ScriptBulkEditDuplicateSlideError({ slideId });
108+
}
109+
110+
if (typeof item.scriptText !== "string") {
111+
throw new ScriptBulkEditPayloadError({ index: i, slideId, scriptText: item.scriptText });
112+
}
113+
114+
usedSlideIds.add(slideId);
115+
normalized.push({ slideId, scriptText: item.scriptText });
116+
}
117+
118+
return normalized;
119+
};
120+
121+
export const processGetProjectScripts = async ({ projectId, userId }) => {
122+
const project = await getProjectSlidesWithScripts(projectId, userId);
123+
if (!project) {
124+
throw new ProjectNotFoundError({ projectId });
125+
}
126+
127+
return {
128+
projectId: project.id.toString(),
129+
scripts: (project.slides || []).map((slide) => ({
130+
slideId: slide.id.toString(),
131+
scriptText: slide.script?.scriptText || "",
132+
})),
133+
};
134+
};
135+
136+
export const processBulkEditProjectScripts = async ({ projectId, userId, scripts }) => {
137+
const normalizedScripts = normalizeBulkEditScripts(scripts);
138+
139+
const project = await getProjectSlidesWithScripts(projectId, userId);
140+
if (!project) {
141+
throw new ProjectNotFoundError({ projectId });
142+
}
143+
144+
const projectSlideIds = new Set((project.slides || []).map((slide) => slide.id.toString()));
145+
const invalidSlideIds = normalizedScripts
146+
.filter((item) => !projectSlideIds.has(item.slideId))
147+
.map((item) => item.slideId);
148+
149+
if (invalidSlideIds.length > 0) {
150+
throw new ScriptBulkEditSlideNotFoundError({
151+
projectId,
152+
slideIds: invalidSlideIds,
153+
});
154+
}
155+
156+
let updatedSlideCount = 0;
157+
let unchangedSlideCount = 0;
158+
const updatedSlideIds = [];
159+
160+
const updateResults = await Promise.all(
161+
normalizedScripts.map((item) => processScriptUpdate(item.slideId, item.scriptText)),
162+
);
163+
164+
for (let i = 0; i < updateResults.length; i++) {
165+
const { isUpdated } = updateResults[i];
166+
if (isUpdated) {
167+
updatedSlideCount += 1;
168+
updatedSlideIds.push(normalizedScripts[i].slideId);
169+
continue;
170+
}
171+
172+
unchangedSlideCount += 1;
173+
}
174+
175+
return {
176+
projectId: project.id.toString(),
177+
requestedSlideCount: normalizedScripts.length,
178+
updatedSlideCount,
179+
unchangedSlideCount,
180+
updatedSlideIds,
181+
};
182+
};

0 commit comments

Comments
 (0)