import { wingPool } from '../db/wingDb.js' import { AuthError } from '../auth/authService.js' // ============================================================ // 인터페이스 // ============================================================ interface PostListItem { sn: number categoryCd: string title: string authorId: string authorName: string viewCnt: number pinnedYn: string regDtm: string } interface PostDetail extends PostListItem { content: string | null mdfcnDtm: string | null } interface ListPostsInput { categoryCd?: string search?: string page?: number size?: number } interface ListPostsResult { items: PostListItem[] totalCount: number page: number size: number } interface CreatePostInput { categoryCd: string title: string content?: string authorId: string pinnedYn?: string } interface UpdatePostInput { title?: string content?: string pinnedYn?: string } // ============================================================ // CRUD 함수 // ============================================================ const VALID_CATEGORIES = ['NOTICE', 'DATA', 'QNA', 'MANUAL'] export async function listPosts(input: ListPostsInput): Promise { const page = input.page && input.page > 0 ? input.page : 1 const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20 const offset = (page - 1) * size let whereClause = `WHERE bp.USE_YN = 'Y'` const params: (string | number)[] = [] let paramIdx = 1 if (input.categoryCd) { whereClause += ` AND bp.CATEGORY_CD = $${paramIdx++}` params.push(input.categoryCd) } if (input.search) { whereClause += ` AND (bp.TITLE ILIKE $${paramIdx} OR u.USER_NM ILIKE $${paramIdx})` params.push(`%${input.search}%`) paramIdx++ } // 전체 건수 const countResult = await wingPool.query( `SELECT COUNT(*) as cnt FROM BOARD_POST bp JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID ${whereClause}`, params ) const totalCount = parseInt(countResult.rows[0].cnt, 10) // 목록 (상단고정 우선, 등록일 내림차순) const listParams = [...params, size, offset] const listResult = await wingPool.query( `SELECT bp.POST_SN as sn, bp.CATEGORY_CD as category_cd, bp.TITLE as title, bp.AUTHOR_ID as author_id, u.USER_NM as author_name, bp.VIEW_CNT as view_cnt, bp.PINNED_YN as pinned_yn, bp.REG_DTM as reg_dtm FROM BOARD_POST bp JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID ${whereClause} ORDER BY bp.PINNED_YN DESC, bp.REG_DTM DESC LIMIT $${paramIdx++} OFFSET $${paramIdx}`, listParams ) const items: PostListItem[] = listResult.rows.map((r: Record) => ({ sn: r.sn as number, categoryCd: r.category_cd as string, title: r.title as string, authorId: r.author_id as string, authorName: r.author_name as string, viewCnt: r.view_cnt as number, pinnedYn: r.pinned_yn as string, regDtm: r.reg_dtm as string, })) return { items, totalCount, page, size } } export async function getPost(postSn: number): Promise { // 조회수 증가 + 상세 조회 (단일 쿼리) const result = await wingPool.query( `UPDATE BOARD_POST SET VIEW_CNT = VIEW_CNT + 1 WHERE POST_SN = $1 AND USE_YN = 'Y' RETURNING POST_SN as sn, CATEGORY_CD as category_cd, TITLE as title, CONTENT as content, AUTHOR_ID as author_id, VIEW_CNT as view_cnt, PINNED_YN as pinned_yn, REG_DTM as reg_dtm, MDFCN_DTM as mdfcn_dtm`, [postSn] ) if (result.rows.length === 0) { throw new AuthError('게시글을 찾을 수 없습니다.', 404) } const row = result.rows[0] // 작성자명 조회 const authorResult = await wingPool.query( 'SELECT USER_NM as name FROM AUTH_USER WHERE USER_ID = $1', [row.author_id] ) return { sn: row.sn, categoryCd: row.category_cd, title: row.title, content: row.content, authorId: row.author_id, authorName: authorResult.rows[0]?.name || '알 수 없음', viewCnt: row.view_cnt, pinnedYn: row.pinned_yn, regDtm: row.reg_dtm, mdfcnDtm: row.mdfcn_dtm, } } export async function createPost(input: CreatePostInput): Promise<{ sn: number }> { if (!VALID_CATEGORIES.includes(input.categoryCd)) { throw new AuthError('유효하지 않은 카테고리입니다.', 400) } if (!input.title || input.title.trim().length === 0) { throw new AuthError('제목은 필수입니다.', 400) } const result = await wingPool.query( `INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, PINNED_YN) VALUES ($1, $2, $3, $4, $5) RETURNING POST_SN as sn`, [input.categoryCd, input.title.trim(), input.content || null, input.authorId, input.pinnedYn || 'N'] ) return { sn: result.rows[0].sn } } export async function updatePost( postSn: number, input: UpdatePostInput, requesterId: string ): Promise { // 게시글 존재 + 작성자 확인 const existing = await wingPool.query( `SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`, [postSn] ) if (existing.rows.length === 0) { throw new AuthError('게시글을 찾을 수 없습니다.', 404) } if (existing.rows[0].author_id !== requesterId) { throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403) } const sets: string[] = [] const params: (string | number | null)[] = [] let idx = 1 if (input.title !== undefined) { sets.push(`TITLE = $${idx++}`) params.push(input.title.trim()) } if (input.content !== undefined) { sets.push(`CONTENT = $${idx++}`) params.push(input.content) } if (input.pinnedYn !== undefined) { sets.push(`PINNED_YN = $${idx++}`) params.push(input.pinnedYn) } if (sets.length === 0) { throw new AuthError('수정할 항목이 없습니다.', 400) } sets.push('MDFCN_DTM = NOW()') params.push(postSn) await wingPool.query( `UPDATE BOARD_POST SET ${sets.join(', ')} WHERE POST_SN = $${idx}`, params ) } // ============================================================ // 매뉴얼 CRUD // ============================================================ interface ManualItem { manualSn: number catgNm: string title: string version: string | null fileTp: string | null fileSz: string | null filePath: string | null authorNm: string | null dwnldCnt: number regDtm: string } interface ListManualsInput { category?: string search?: string } interface CreateManualInput { catgNm: string title: string version?: string fileTp?: string fileSz?: string filePath?: string authorNm?: string } interface UpdateManualInput { catgNm?: string title?: string version?: string fileTp?: string fileSz?: string filePath?: string } function rowToManual(r: Record): ManualItem { return { manualSn: r.manual_sn as number, catgNm: r.catg_nm as string, title: r.title as string, version: r.version as string | null, fileTp: r.file_tp as string | null, fileSz: r.file_sz as string | null, filePath: r.file_path as string | null, authorNm: r.author_nm as string | null, dwnldCnt: r.dwnld_cnt as number, regDtm: r.reg_dtm as string, } } export async function listManuals(input: ListManualsInput): Promise { const conditions: string[] = ["USE_YN = 'Y'"] const params: string[] = [] let idx = 1 if (input.category) { conditions.push(`CATG_NM = $${idx++}`) params.push(input.category) } if (input.search) { conditions.push(`(TITLE ILIKE $${idx} OR AUTHOR_NM ILIKE $${idx})`) params.push(`%${input.search}%`) idx++ } const { rows } = await wingPool.query( `SELECT MANUAL_SN, CATG_NM, TITLE, VERSION, FILE_TP, FILE_SZ, FILE_PATH, AUTHOR_NM, DWNLD_CNT, REG_DTM FROM MANUAL_FILE WHERE ${conditions.join(' AND ')} ORDER BY REG_DTM DESC`, params ) return rows.map((r: Record) => rowToManual(r)) } export async function createManual(input: CreateManualInput): Promise<{ manualSn: number }> { if (!input.title || input.title.trim().length === 0) { throw new AuthError('제목은 필수입니다.', 400) } const { rows } = await wingPool.query( `INSERT INTO MANUAL_FILE (CATG_NM, TITLE, VERSION, FILE_TP, FILE_SZ, FILE_PATH, AUTHOR_NM) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING MANUAL_SN`, [input.catgNm, input.title.trim(), input.version || null, input.fileTp || null, input.fileSz || null, input.filePath || null, input.authorNm || null] ) return { manualSn: rows[0].manual_sn } } export async function updateManual(manualSn: number, input: UpdateManualInput): Promise { const existing = await wingPool.query( `SELECT MANUAL_SN FROM MANUAL_FILE WHERE MANUAL_SN = $1 AND USE_YN = 'Y'`, [manualSn] ) if (existing.rows.length === 0) { throw new AuthError('매뉴얼을 찾을 수 없습니다.', 404) } const sets: string[] = [] const params: (string | number | null)[] = [] let idx = 1 if (input.catgNm !== undefined) { sets.push(`CATG_NM = $${idx++}`); params.push(input.catgNm) } if (input.title !== undefined) { sets.push(`TITLE = $${idx++}`); params.push(input.title.trim()) } if (input.version !== undefined) { sets.push(`VERSION = $${idx++}`); params.push(input.version) } if (input.fileTp !== undefined) { sets.push(`FILE_TP = $${idx++}`); params.push(input.fileTp) } if (input.fileSz !== undefined) { sets.push(`FILE_SZ = $${idx++}`); params.push(input.fileSz) } if (input.filePath !== undefined) { sets.push(`FILE_PATH = $${idx++}`); params.push(input.filePath) } if (sets.length === 0) { throw new AuthError('수정할 항목이 없습니다.', 400) } sets.push('MDFCN_DTM = NOW()') params.push(manualSn) await wingPool.query( `UPDATE MANUAL_FILE SET ${sets.join(', ')} WHERE MANUAL_SN = $${idx}`, params ) } export async function deleteManual(manualSn: number): Promise { const existing = await wingPool.query( `SELECT MANUAL_SN FROM MANUAL_FILE WHERE MANUAL_SN = $1 AND USE_YN = 'Y'`, [manualSn] ) if (existing.rows.length === 0) { throw new AuthError('매뉴얼을 찾을 수 없습니다.', 404) } await wingPool.query( `UPDATE MANUAL_FILE SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE MANUAL_SN = $1`, [manualSn] ) } export async function incrementManualDownload(manualSn: number): Promise { await wingPool.query( `UPDATE MANUAL_FILE SET DWNLD_CNT = DWNLD_CNT + 1 WHERE MANUAL_SN = $1 AND USE_YN = 'Y'`, [manualSn] ) } // ============================================================ // 게시글 삭제 // ============================================================ export async function deletePost(postSn: number, requesterId: string): Promise { // 게시글 존재 + 작성자 확인 const existing = await wingPool.query( `SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`, [postSn] ) if (existing.rows.length === 0) { throw new AuthError('게시글을 찾을 수 없습니다.', 404) } if (existing.rows[0].author_id !== requesterId) { throw new AuthError('본인의 게시글만 삭제할 수 있습니다.', 403) } // 논리 삭제 await wingPool.query( `UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`, [postSn] ) } /** 관리자 전용 삭제 — 소유자 검증 없이 논리 삭제 */ export async function adminDeletePost(postSn: number): Promise { const existing = await wingPool.query( `SELECT POST_SN FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`, [postSn] ) if (existing.rows.length === 0) { throw new AuthError('게시글을 찾을 수 없습니다.', 404) } await wingPool.query( `UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`, [postSn] ) }