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 ) } 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] ) }