diff --git a/backend-node/src/services/componentStandardService.ts b/backend-node/src/services/componentStandardService.ts index 3ac00742..496a4ae7 100644 --- a/backend-node/src/services/componentStandardService.ts +++ b/backend-node/src/services/componentStandardService.ts @@ -1,6 +1,4 @@ -import { PrismaClient } from "@prisma/client"; - -const prisma = new PrismaClient(); +import { query, queryOne, transaction } from "../database/db"; export interface ComponentStandardData { component_code: string; @@ -49,49 +47,78 @@ class ComponentStandardService { offset = 0, } = params; - const where: any = {}; + const whereConditions: string[] = []; + const values: any[] = []; + let paramIndex = 1; // 활성화 상태 필터 if (active) { - where.is_active = active; + whereConditions.push(`is_active = $${paramIndex++}`); + values.push(active); } // 카테고리 필터 if (category && category !== "all") { - where.category = category; + whereConditions.push(`category = $${paramIndex++}`); + values.push(category); } // 공개 여부 필터 if (is_public) { - where.is_public = is_public; + whereConditions.push(`is_public = $${paramIndex++}`); + values.push(is_public); } // 회사별 필터 (공개 컴포넌트 + 해당 회사 컴포넌트) if (company_code) { - where.OR = [{ is_public: "Y" }, { company_code }]; + whereConditions.push( + `(is_public = 'Y' OR company_code = $${paramIndex++})` + ); + values.push(company_code); } // 검색 조건 if (search) { - where.OR = [ - ...(where.OR || []), - { component_name: { contains: search, mode: "insensitive" } }, - { component_name_eng: { contains: search, mode: "insensitive" } }, - { description: { contains: search, mode: "insensitive" } }, - ]; + whereConditions.push( + `(component_name ILIKE $${paramIndex} OR component_name_eng ILIKE $${paramIndex} OR description ILIKE $${paramIndex})` + ); + values.push(`%${search}%`); + paramIndex++; } - const orderBy: any = {}; - orderBy[sort] = order; + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; - const components = await prisma.component_standards.findMany({ - where, - orderBy, - take: limit, - skip: offset, - }); + // 정렬 컬럼 검증 (SQL 인젝션 방지) + const validSortColumns = [ + "sort_order", + "component_name", + "category", + "created_date", + "updated_date", + ]; + const sortColumn = validSortColumns.includes(sort) ? sort : "sort_order"; + const sortOrder = order === "desc" ? "DESC" : "ASC"; - const total = await prisma.component_standards.count({ where }); + // 컴포넌트 조회 + const components = await query( + `SELECT * FROM component_standards + ${whereClause} + ORDER BY ${sortColumn} ${sortOrder} + ${limit ? `LIMIT $${paramIndex++}` : ""} + ${limit ? `OFFSET $${paramIndex++}` : ""}`, + limit ? [...values, limit, offset] : values + ); + + // 전체 개수 조회 + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM component_standards ${whereClause}`, + values + ); + + const total = parseInt(countResult?.count || "0"); return { components, @@ -105,9 +132,10 @@ class ComponentStandardService { * 컴포넌트 상세 조회 */ async getComponent(component_code: string) { - const component = await prisma.component_standards.findUnique({ - where: { component_code }, - }); + const component = await queryOne( + `SELECT * FROM component_standards WHERE component_code = $1`, + [component_code] + ); if (!component) { throw new Error(`컴포넌트를 찾을 수 없습니다: ${component_code}`); @@ -121,9 +149,10 @@ class ComponentStandardService { */ async createComponent(data: ComponentStandardData) { // 중복 코드 확인 - const existing = await prisma.component_standards.findUnique({ - where: { component_code: data.component_code }, - }); + const existing = await queryOne( + `SELECT * FROM component_standards WHERE component_code = $1`, + [data.component_code] + ); if (existing) { throw new Error( @@ -138,13 +167,31 @@ class ComponentStandardService { delete (createData as any).active; } - const component = await prisma.component_standards.create({ - data: { - ...createData, - created_date: new Date(), - updated_date: new Date(), - }, - }); + const component = await queryOne( + `INSERT INTO component_standards + (component_code, component_name, component_name_eng, description, category, + icon_name, default_size, component_config, preview_image, sort_order, + is_active, is_public, company_code, created_by, updated_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW()) + RETURNING *`, + [ + createData.component_code, + createData.component_name, + createData.component_name_eng || null, + createData.description || null, + createData.category, + createData.icon_name || null, + createData.default_size || null, + createData.component_config, + createData.preview_image || null, + createData.sort_order || 0, + createData.is_active || "Y", + createData.is_public || "N", + createData.company_code, + createData.created_by || null, + createData.updated_by || null, + ] + ); return component; } @@ -165,13 +212,41 @@ class ComponentStandardService { delete (updateData as any).active; } - const component = await prisma.component_standards.update({ - where: { component_code }, - data: { - ...updateData, - updated_date: new Date(), - }, - }); + // 동적 UPDATE 쿼리 생성 + const updateFields: string[] = ["updated_date = NOW()"]; + const values: any[] = []; + let paramIndex = 1; + + const fieldMapping: { [key: string]: string } = { + component_name: "component_name", + component_name_eng: "component_name_eng", + description: "description", + category: "category", + icon_name: "icon_name", + default_size: "default_size", + component_config: "component_config", + preview_image: "preview_image", + sort_order: "sort_order", + is_active: "is_active", + is_public: "is_public", + company_code: "company_code", + updated_by: "updated_by", + }; + + for (const [key, dbField] of Object.entries(fieldMapping)) { + if (key in updateData) { + updateFields.push(`${dbField} = $${paramIndex++}`); + values.push((updateData as any)[key]); + } + } + + const component = await queryOne( + `UPDATE component_standards + SET ${updateFields.join(", ")} + WHERE component_code = $${paramIndex} + RETURNING *`, + [...values, component_code] + ); return component; } @@ -182,9 +257,10 @@ class ComponentStandardService { async deleteComponent(component_code: string) { const existing = await this.getComponent(component_code); - await prisma.component_standards.delete({ - where: { component_code }, - }); + await query( + `DELETE FROM component_standards WHERE component_code = $1`, + [component_code] + ); return { message: `컴포넌트가 삭제되었습니다: ${component_code}` }; } @@ -195,14 +271,16 @@ class ComponentStandardService { async updateSortOrder( updates: Array<{ component_code: string; sort_order: number }> ) { - const transactions = updates.map(({ component_code, sort_order }) => - prisma.component_standards.update({ - where: { component_code }, - data: { sort_order, updated_date: new Date() }, - }) - ); - - await prisma.$transaction(transactions); + await transaction(async (client) => { + for (const { component_code, sort_order } of updates) { + await client.query( + `UPDATE component_standards + SET sort_order = $1, updated_date = NOW() + WHERE component_code = $2`, + [sort_order, component_code] + ); + } + }); return { message: "정렬 순서가 업데이트되었습니다." }; } @@ -218,33 +296,38 @@ class ComponentStandardService { const source = await this.getComponent(source_code); // 새 코드 중복 확인 - const existing = await prisma.component_standards.findUnique({ - where: { component_code: new_code }, - }); + const existing = await queryOne( + `SELECT * FROM component_standards WHERE component_code = $1`, + [new_code] + ); if (existing) { throw new Error(`이미 존재하는 컴포넌트 코드입니다: ${new_code}`); } - const component = await prisma.component_standards.create({ - data: { - component_code: new_code, - component_name: new_name, - component_name_eng: source?.component_name_eng, - description: source?.description, - category: source?.category, - icon_name: source?.icon_name, - default_size: source?.default_size as any, - component_config: source?.component_config as any, - preview_image: source?.preview_image, - sort_order: source?.sort_order, - is_active: source?.is_active, - is_public: source?.is_public, - company_code: source?.company_code || "DEFAULT", - created_date: new Date(), - updated_date: new Date(), - }, - }); + const component = await queryOne( + `INSERT INTO component_standards + (component_code, component_name, component_name_eng, description, category, + icon_name, default_size, component_config, preview_image, sort_order, + is_active, is_public, company_code, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW()) + RETURNING *`, + [ + new_code, + new_name, + source?.component_name_eng, + source?.description, + source?.category, + source?.icon_name, + source?.default_size, + source?.component_config, + source?.preview_image, + source?.sort_order, + source?.is_active, + source?.is_public, + source?.company_code || "DEFAULT", + ] + ); return component; } @@ -253,19 +336,20 @@ class ComponentStandardService { * 카테고리 목록 조회 */ async getCategories(company_code?: string) { - const where: any = { - is_active: "Y", - }; + const whereConditions: string[] = ["is_active = 'Y'"]; + const values: any[] = []; if (company_code) { - where.OR = [{ is_public: "Y" }, { company_code }]; + whereConditions.push(`(is_public = 'Y' OR company_code = $1)`); + values.push(company_code); } - const categories = await prisma.component_standards.findMany({ - where, - select: { category: true }, - distinct: ["category"], - }); + const whereClause = `WHERE ${whereConditions.join(" AND ")}`; + + const categories = await query<{ category: string }>( + `SELECT DISTINCT category FROM component_standards ${whereClause} ORDER BY category`, + values + ); return categories .map((item) => item.category) @@ -276,36 +360,48 @@ class ComponentStandardService { * 컴포넌트 통계 */ async getStatistics(company_code?: string) { - const where: any = { - is_active: "Y", - }; + const whereConditions: string[] = ["is_active = 'Y'"]; + const values: any[] = []; if (company_code) { - where.OR = [{ is_public: "Y" }, { company_code }]; + whereConditions.push(`(is_public = 'Y' OR company_code = $1)`); + values.push(company_code); } - const total = await prisma.component_standards.count({ where }); + const whereClause = `WHERE ${whereConditions.join(" AND ")}`; - const byCategory = await prisma.component_standards.groupBy({ - by: ["category"], - where, - _count: { category: true }, - }); + // 전체 개수 + const totalResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM component_standards ${whereClause}`, + values + ); + const total = parseInt(totalResult?.count || "0"); - const byStatus = await prisma.component_standards.groupBy({ - by: ["is_active"], - _count: { is_active: true }, - }); + // 카테고리별 집계 + const byCategory = await query<{ category: string; count: string }>( + `SELECT category, COUNT(*) as count + FROM component_standards + ${whereClause} + GROUP BY category`, + values + ); + + // 상태별 집계 + const byStatus = await query<{ is_active: string; count: string }>( + `SELECT is_active, COUNT(*) as count + FROM component_standards + GROUP BY is_active` + ); return { total, byCategory: byCategory.map((item) => ({ category: item.category, - count: item._count.category, + count: parseInt(item.count), })), byStatus: byStatus.map((item) => ({ status: item.is_active, - count: item._count.is_active, + count: parseInt(item.count), })), }; } @@ -317,16 +413,21 @@ class ComponentStandardService { component_code: string, company_code?: string ): Promise { - const whereClause: any = { component_code }; + const whereConditions: string[] = ["component_code = $1"]; + const values: any[] = [component_code]; // 회사 코드가 있고 "*"가 아닌 경우에만 조건 추가 if (company_code && company_code !== "*") { - whereClause.company_code = company_code; + whereConditions.push("company_code = $2"); + values.push(company_code); } - const existingComponent = await prisma.component_standards.findFirst({ - where: whereClause, - }); + const whereClause = `WHERE ${whereConditions.join(" AND ")}`; + + const existingComponent = await queryOne( + `SELECT * FROM component_standards ${whereClause} LIMIT 1`, + values + ); return !!existingComponent; }