diff --git a/src/controllers/script.controller.js b/src/controllers/script.controller.js index d99618f..e54ab40 100644 --- a/src/controllers/script.controller.js +++ b/src/controllers/script.controller.js @@ -1,5 +1,7 @@ import { scriptResponseDTO, scriptVersionResponseDTO } from "../dtos/script.dto.js"; import { + processBulkEditProjectScripts, + processGetProjectScripts, processScriptGet, processScriptRestore, processScriptUpdate, @@ -248,3 +250,132 @@ export const handleRestoreVersion = async (req, res, next) => { next(error); } }; + +/** + * @swagger + * /presentations/{projectId}/scripts: + * get: + * summary: 프로젝트 전체 대본 조회 + * description: 프로젝트의 슬라이드 순서대로 현재 대본 목록을 조회합니다. + * tags: [Script] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: projectId + * required: true + * schema: + * type: string + * example: "123" + * description: 대본을 가져올 프로젝트 ID + * responses: + * 200: + * description: 프로젝트 대본 조회 성공 + * content: + * application/json: + * example: + * resultType: "SUCCESS" + * error: null + * success: + * message: "프로젝트 대본이 성공적으로 조회되었습니다." + * projectId: "123" + * scripts: + * - slideId: "1" + * scriptText: "첫 번째 슬라이드 대본" + * - slideId: "2" + * scriptText: "" + */ +export const handleGetProjectScripts = async (req, res, next) => { + try { + const { projectId } = req.params; + const userId = req.user.id; + const result = await processGetProjectScripts({ projectId, userId }); + + res.status(200).json({ + resultType: "SUCCESS", + error: null, + success: { + message: "프로젝트 대본이 성공적으로 조회되었습니다.", + ...result, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * @swagger + * /presentations/{projectId}/scripts/bulk-edit: + * patch: + * summary: 프로젝트 대본 일괄 수정 + * description: 프로젝트에 속한 여러 슬라이드의 대본을 한 번에 저장합니다. + * tags: [Script] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: projectId + * required: true + * schema: + * type: string + * example: "123" + * description: 대본을 수정할 프로젝트 ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - scripts + * properties: + * scripts: + * type: array + * items: + * type: object + * required: + * - slideId + * - scriptText + * properties: + * slideId: + * type: string + * example: "1" + * scriptText: + * type: string + * example: "수정된 대본입니다." + * responses: + * 200: + * description: 프로젝트 대본 일괄 수정 성공 + * content: + * application/json: + * example: + * resultType: "SUCCESS" + * error: null + * success: + * message: "대본 일괄 수정이 완료되었습니다." + * projectId: "123" + * requestedSlideCount: 2 + * updatedSlideCount: 1 + * unchangedSlideCount: 1 + * updatedSlideIds: ["1"] + */ +export const handleBulkEditProjectScripts = async (req, res, next) => { + try { + const { projectId } = req.params; + const userId = req.user.id; + const { scripts } = req.body; + const result = await processBulkEditProjectScripts({ projectId, userId, scripts }); + + res.status(200).json({ + resultType: "SUCCESS", + error: null, + success: { + message: "대본 일괄 수정이 완료되었습니다.", + ...result, + }, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/errors/script.error.js b/src/errors/script.error.js index 02af747..629af7f 100644 --- a/src/errors/script.error.js +++ b/src/errors/script.error.js @@ -11,3 +11,21 @@ export class VersionNotFoundError extends BaseError { super("버전이 존재하지 않습니다.", 404, "SC002", data); } } + +export class ScriptBulkEditPayloadError extends BaseError { + constructor(data) { + super("유효한 대본 목록이 필요합니다.", 400, "SC003", data); + } +} + +export class ScriptBulkEditDuplicateSlideError extends BaseError { + constructor(data) { + super("중복된 슬라이드 ID가 포함되어 있습니다.", 400, "SC004", data); + } +} + +export class ScriptBulkEditSlideNotFoundError extends BaseError { + constructor(data) { + super("프로젝트에 속하지 않은 슬라이드가 포함되어 있습니다.", 400, "SC005", data); + } +} diff --git a/src/repositories/script.repository.js b/src/repositories/script.repository.js index 9f27390..a54fed5 100644 --- a/src/repositories/script.repository.js +++ b/src/repositories/script.repository.js @@ -69,6 +69,35 @@ export const getScriptVersionList = async (slideId) => { }); }; +// 프로젝트 슬라이드/대본 목록 조회 (일괄 수정용) +export const getProjectSlidesWithScripts = async (projectId, userId) => { + return await prisma.project.findFirst({ + where: { + id: BigInt(projectId), + userId: BigInt(userId), + isDeleted: false, + }, + select: { + id: true, + slides: { + where: { + isDeleted: false, + }, + select: { + id: true, + slideNum: true, + script: { + select: { + scriptText: true, + }, + }, + }, + orderBy: [{ slideNum: "asc" }, { id: "asc" }], + }, + }, + }); +}; + // 대본 버전 복원 export const postScriptVersion = async (slideId, versionNumber) => { return await prisma.$transaction(async (tx) => { diff --git a/src/routes/project.route.js b/src/routes/project.route.js index e0edbca..fbc0741 100644 --- a/src/routes/project.route.js +++ b/src/routes/project.route.js @@ -7,6 +7,10 @@ import { handleGetProjectName, handleUpdateProjectName, } from "../controllers/project.controller.js"; +import { + handleBulkEditProjectScripts, + handleGetProjectScripts, +} from "../controllers/script.controller.js"; import { handleGetRecentComments, handleGetSlideAnalytics, @@ -22,6 +26,12 @@ router.post("/", isLogin, handleCreateProject); // 프로젝트 목록 조회/검색 router.get("/", isLogin, handleGetProjectList); +// 프로젝트 전체 대본 조회 +router.get("/:projectId/scripts", isLogin, handleGetProjectScripts); + +// 프로젝트 대본 일괄 수정 +router.patch("/:projectId/scripts/bulk-edit", isLogin, handleBulkEditProjectScripts); + // 프로젝트 이름 업데이트 router.patch("/:projectId", isLogin, handleUpdateProjectName); diff --git a/src/services/script.service.js b/src/services/script.service.js index 5f08e9f..3399139 100644 --- a/src/services/script.service.js +++ b/src/services/script.service.js @@ -1,9 +1,16 @@ import { + getProjectSlidesWithScripts, getScriptText, getScriptVersionList, postScriptVersion, updateScriptText, } from "../repositories/script.repository.js"; +import { ProjectNotFoundError } from "../errors/project.error.js"; +import { + ScriptBulkEditDuplicateSlideError, + ScriptBulkEditPayloadError, + ScriptBulkEditSlideNotFoundError, +} from "../errors/script.error.js"; export const processScriptUpdate = async (slideId, text) => { try { @@ -76,3 +83,100 @@ export const processScriptRestore = async (id, versionNumber) => { throw error; } }; + +const normalizeBulkEditScripts = (scripts) => { + if (!Array.isArray(scripts) || scripts.length < 1) { + throw new ScriptBulkEditPayloadError({ scripts }); + } + + const normalized = []; + const usedSlideIds = new Set(); + + for (let i = 0; i < scripts.length; i++) { + const item = scripts[i]; + if (!item || typeof item !== "object") { + throw new ScriptBulkEditPayloadError({ index: i, item }); + } + + const slideId = item.slideId != null ? String(item.slideId) : ""; + if (!slideId || !/^\d+$/.test(slideId)) { + throw new ScriptBulkEditPayloadError({ index: i, slideId: item.slideId }); + } + + if (usedSlideIds.has(slideId)) { + throw new ScriptBulkEditDuplicateSlideError({ slideId }); + } + + if (typeof item.scriptText !== "string") { + throw new ScriptBulkEditPayloadError({ index: i, slideId, scriptText: item.scriptText }); + } + + usedSlideIds.add(slideId); + normalized.push({ slideId, scriptText: item.scriptText }); + } + + return normalized; +}; + +export const processGetProjectScripts = async ({ projectId, userId }) => { + const project = await getProjectSlidesWithScripts(projectId, userId); + if (!project) { + throw new ProjectNotFoundError({ projectId }); + } + + return { + projectId: project.id.toString(), + scripts: (project.slides || []).map((slide) => ({ + slideId: slide.id.toString(), + scriptText: slide.script?.scriptText || "", + })), + }; +}; + +export const processBulkEditProjectScripts = async ({ projectId, userId, scripts }) => { + const normalizedScripts = normalizeBulkEditScripts(scripts); + + const project = await getProjectSlidesWithScripts(projectId, userId); + if (!project) { + throw new ProjectNotFoundError({ projectId }); + } + + const projectSlideIds = new Set((project.slides || []).map((slide) => slide.id.toString())); + const invalidSlideIds = normalizedScripts + .filter((item) => !projectSlideIds.has(item.slideId)) + .map((item) => item.slideId); + + if (invalidSlideIds.length > 0) { + throw new ScriptBulkEditSlideNotFoundError({ + projectId, + slideIds: invalidSlideIds, + }); + } + + let updatedSlideCount = 0; + let unchangedSlideCount = 0; + const updatedSlideIds = []; + + const updateResults = await Promise.all( + normalizedScripts.map((item) => processScriptUpdate(item.slideId, item.scriptText)), + ); + + for (let i = 0; i < updateResults.length; i++) { + const { isUpdated } = updateResults[i]; + if (isUpdated) { + updatedSlideCount += 1; + updatedSlideIds.push(normalizedScripts[i].slideId); + continue; + } + + unchangedSlideCount += 1; + } + + return { + projectId: project.id.toString(), + requestedSlideCount: normalizedScripts.length, + updatedSlideCount, + unchangedSlideCount, + updatedSlideIds, + }; +}; diff --git a/tests/script/script.bulk-edit.service.test.js b/tests/script/script.bulk-edit.service.test.js new file mode 100644 index 0000000..7182900 --- /dev/null +++ b/tests/script/script.bulk-edit.service.test.js @@ -0,0 +1,204 @@ +import { jest } from "@jest/globals"; + +const mockGetProjectSlidesWithScripts = jest.fn(); +const mockGetScriptText = jest.fn(); +const mockGetScriptVersionList = jest.fn(); +const mockPostScriptVersion = jest.fn(); +const mockUpdateScriptText = jest.fn(); + +jest.unstable_mockModule("../../src/repositories/script.repository.js", () => ({ + getProjectSlidesWithScripts: mockGetProjectSlidesWithScripts, + getScriptText: mockGetScriptText, + getScriptVersionList: mockGetScriptVersionList, + postScriptVersion: mockPostScriptVersion, + updateScriptText: mockUpdateScriptText, +})); + +const { + processBulkEditProjectScripts, + processGetProjectScripts, +} = await import("../../src/services/script.service.js"); + +const { ProjectNotFoundError } = await import("../../src/errors/project.error.js"); +const { + ScriptBulkEditDuplicateSlideError, + ScriptBulkEditPayloadError, +} = await import("../../src/errors/script.error.js"); + +describe("script.bulk-edit.service", () => { + beforeEach(() => { + mockGetProjectSlidesWithScripts.mockReset(); + mockGetScriptText.mockReset(); + mockGetScriptVersionList.mockReset(); + mockPostScriptVersion.mockReset(); + mockUpdateScriptText.mockReset(); + + mockGetScriptText.mockResolvedValue(null); + mockUpdateScriptText.mockImplementation(async (slideId, text, charCount, duration) => ({ + slideId: BigInt(slideId), + charCount, + scriptText: text, + estimatedDurationSeconds: duration, + createdAt: new Date("2026-02-18T00:00:00.000Z"), + updatedAt: new Date("2026-02-18T00:00:00.000Z"), + })); + }); + + test("processGetProjectScripts returns ordered script list", async () => { + mockGetProjectSlidesWithScripts.mockResolvedValue({ + id: 10n, + slides: [ + { id: 101n, slideNum: 1n, script: { scriptText: "첫번째" } }, + { id: 102n, slideNum: 2n, script: null }, + ], + }); + + const result = await processGetProjectScripts({ + projectId: "10", + userId: 5n, + }); + + expect(result).toEqual({ + projectId: "10", + scripts: [ + { slideId: "101", scriptText: "첫번째" }, + { slideId: "102", scriptText: "" }, + ], + }); + }); + + test("processGetProjectScripts throws 404 when project is missing", async () => { + mockGetProjectSlidesWithScripts.mockResolvedValue(null); + + await expect( + processGetProjectScripts({ + projectId: "999", + userId: 5n, + }) + ).rejects.toBeInstanceOf(ProjectNotFoundError); + }); + + test("processBulkEditProjectScripts updates and counts unchanged scripts", async () => { + mockGetProjectSlidesWithScripts.mockResolvedValue({ + id: 11n, + slides: [ + { id: 201n, slideNum: 1n, script: { scriptText: "기존1" } }, + { id: 202n, slideNum: 2n, script: { scriptText: "기존2" } }, + ], + }); + + mockGetScriptText.mockImplementation(async (slideId) => { + if (slideId === "201") { + return { + id: 301n, + slideId: 201n, + scriptText: "유지", + charCount: 2, + estimatedDurationSeconds: 1, + createdAt: new Date("2026-02-18T00:00:00.000Z"), + updatedAt: new Date("2026-02-18T00:00:00.000Z"), + }; + } + + return { + id: 302n, + slideId: 202n, + scriptText: "이전", + charCount: 2, + estimatedDurationSeconds: 1, + createdAt: new Date("2026-02-18T00:00:00.000Z"), + updatedAt: new Date("2026-02-18T00:00:00.000Z"), + }; + }); + + const result = await processBulkEditProjectScripts({ + projectId: "11", + userId: 5n, + scripts: [ + { slideId: "201", scriptText: "유지" }, + { slideId: "202", scriptText: "수정" }, + ], + }); + + expect(mockUpdateScriptText).toHaveBeenCalledTimes(1); + expect(mockUpdateScriptText.mock.calls[0][0]).toBe("202"); + expect(result).toEqual({ + projectId: "11", + requestedSlideCount: 2, + updatedSlideCount: 1, + unchangedSlideCount: 1, + updatedSlideIds: ["202"], + }); + }); + + test("throws payload error when scripts array is empty", async () => { + await expect( + processBulkEditProjectScripts({ + projectId: "11", + userId: 5n, + scripts: [], + }) + ).rejects.toBeInstanceOf(ScriptBulkEditPayloadError); + }); + + test("throws payload error when slideId format is invalid", async () => { + await expect( + processBulkEditProjectScripts({ + projectId: "11", + userId: 5n, + scripts: [{ slideId: "abc", scriptText: "x" }], + }) + ).rejects.toBeInstanceOf(ScriptBulkEditPayloadError); + }); + + test("throws duplicate error when slideId is duplicated", async () => { + await expect( + processBulkEditProjectScripts({ + projectId: "11", + userId: 5n, + scripts: [ + { slideId: "201", scriptText: "a" }, + { slideId: "201", scriptText: "b" }, + ], + }) + ).rejects.toBeInstanceOf(ScriptBulkEditDuplicateSlideError); + }); + + test("throws 404 when project is missing in bulk edit", async () => { + mockGetProjectSlidesWithScripts.mockResolvedValue(null); + + await expect( + processBulkEditProjectScripts({ + projectId: "404", + userId: 5n, + scripts: [{ slideId: "1", scriptText: "a" }], + }) + ).rejects.toBeInstanceOf(ProjectNotFoundError); + }); + + test("throws slide not found error with all invalid slideIds", async () => { + mockGetProjectSlidesWithScripts.mockResolvedValue({ + id: 11n, + slides: [{ id: 201n, slideNum: 1n, script: { scriptText: "x" } }], + }); + + await expect( + processBulkEditProjectScripts({ + projectId: "11", + userId: 5n, + scripts: [ + { slideId: "201", scriptText: "a" }, + { slideId: "999", scriptText: "b" }, + { slideId: "998", scriptText: "c" }, + ], + }), + ).rejects.toMatchObject({ + data: { + projectId: "11", + slideIds: ["999", "998"], + }, + }); + expect(mockGetScriptText).not.toHaveBeenCalled(); + expect(mockUpdateScriptText).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/script/script.controller.test.js b/tests/script/script.controller.test.js index ef23778..c7bcbe7 100644 --- a/tests/script/script.controller.test.js +++ b/tests/script/script.controller.test.js @@ -1,11 +1,15 @@ import { jest } from "@jest/globals"; +const mockProcessBulkEditProjectScripts = jest.fn(); +const mockProcessGetProjectScripts = jest.fn(); const mockProcessScriptGet = jest.fn(); const mockProcessScriptRestore = jest.fn(); const mockProcessScriptUpdate = jest.fn(); const mockProcessScriptVersionGet = jest.fn(); jest.unstable_mockModule("../../src/services/script.service.js", () => ({ + processBulkEditProjectScripts: mockProcessBulkEditProjectScripts, + processGetProjectScripts: mockProcessGetProjectScripts, processScriptGet: mockProcessScriptGet, processScriptRestore: mockProcessScriptRestore, processScriptUpdate: mockProcessScriptUpdate, @@ -13,15 +17,16 @@ jest.unstable_mockModule("../../src/services/script.service.js", () => ({ })); const { + handleBulkEditProjectScripts, + handleGetProjectScripts, handleGetScript, handleGetScriptVersion, handleRestoreVersion, handleUploadScript, } = await import("../../src/controllers/script.controller.js"); -const { scriptResponseDTO, scriptVersionResponseDTO } = await import( - "../../src/dtos/script.dto.js" -); +const { scriptResponseDTO, scriptVersionResponseDTO } = + await import("../../src/dtos/script.dto.js"); function createRes() { return { @@ -32,6 +37,8 @@ function createRes() { describe("script.controller", () => { beforeEach(() => { + mockProcessBulkEditProjectScripts.mockReset(); + mockProcessGetProjectScripts.mockReset(); mockProcessScriptGet.mockReset(); mockProcessScriptRestore.mockReset(); mockProcessScriptUpdate.mockReset(); @@ -97,9 +104,7 @@ describe("script.controller", () => { }); test("handleGetScriptVersion returns dto list", async () => { - const versions = [ - { versionNumber: 1, scriptText: "v1", charCount: 2, createdAt: new Date() }, - ]; + const versions = [{ versionNumber: 1, scriptText: "v1", charCount: 2, createdAt: new Date() }]; mockProcessScriptVersionGet.mockResolvedValue(versions); const req = { params: { slideId: "3" } }; @@ -145,4 +150,104 @@ describe("script.controller", () => { }, }); }); + + test("handleGetProjectScripts returns all scripts", async () => { + const projectScripts = { + projectId: "12", + scripts: [ + { slideId: "11", scriptText: "첫번째" }, + { slideId: "12", scriptText: "" }, + ], + }; + + mockProcessGetProjectScripts.mockResolvedValue(projectScripts); + + const req = { + params: { projectId: "12" }, + user: { id: 99n }, + }; + const res = createRes(); + const next = jest.fn(); + + await handleGetProjectScripts(req, res, next); + + expect(mockProcessGetProjectScripts).toHaveBeenCalledWith({ + projectId: "12", + userId: 99n, + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + resultType: "SUCCESS", + error: null, + success: { + message: "프로젝트 대본이 성공적으로 조회되었습니다.", + ...projectScripts, + }, + }); + expect(next).not.toHaveBeenCalled(); + }); + + test("handleBulkEditProjectScripts returns edit summary", async () => { + const summary = { + projectId: "12", + requestedSlideCount: 2, + updatedSlideCount: 1, + unchangedSlideCount: 1, + updatedSlideIds: ["11"], + }; + + mockProcessBulkEditProjectScripts.mockResolvedValue(summary); + + const req = { + params: { projectId: "12" }, + user: { id: 99n }, + body: { + scripts: [ + { slideId: "11", scriptText: "수정" }, + { slideId: "12", scriptText: "" }, + ], + }, + }; + const res = createRes(); + const next = jest.fn(); + + await handleBulkEditProjectScripts(req, res, next); + + expect(mockProcessBulkEditProjectScripts).toHaveBeenCalledWith({ + projectId: "12", + userId: 99n, + scripts: [ + { slideId: "11", scriptText: "수정" }, + { slideId: "12", scriptText: "" }, + ], + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + resultType: "SUCCESS", + error: null, + success: { + message: "대본 일괄 수정이 완료되었습니다.", + ...summary, + }, + }); + expect(next).not.toHaveBeenCalled(); + }); + + test("handleBulkEditProjectScripts forwards service error", async () => { + const error = new Error("bulk edit failed"); + mockProcessBulkEditProjectScripts.mockRejectedValue(error); + + const req = { + params: { projectId: "12" }, + user: { id: 99n }, + body: { scripts: [] }, + }; + const res = createRes(); + const next = jest.fn(); + + await handleBulkEditProjectScripts(req, res, next); + + expect(next).toHaveBeenCalledWith(error); + expect(res.status).not.toHaveBeenCalled(); + }); });