wing-ops/backend/src/board/boardService.ts
htlee 2b88455a30 feat(backend): DB 통합 + 게시판 CRUD API + RBAC 적용 가이드
- wing + wing_auth DB를 wing 단일 DB로 통합 (wing/auth 스키마 분리)
- wingPool 단일 Pool + search_path 설정, authPool 하위 호환 유지
- 게시판 BOARD_POST DDL + 초기 데이터 10건 마이그레이션
- boardService/boardRouter CRUD 구현 (페이징, 검색, 소유자 검증, 논리삭제)
- requirePermission 카테고리별 서브리소스 동적 적용 (board:notice, board:qna 등)
- 프론트엔드 boardApi 서비스 + BoardListTable mock→API 전환
- CRUD-API-GUIDE (범용 가이드 + 게시판 튜토리얼) 문서 작성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:37:14 +09:00

244 lines
6.6 KiB
TypeScript

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<ListPostsResult> {
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<string, unknown>) => ({
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<PostDetail> {
// 조회수 증가 + 상세 조회 (단일 쿼리)
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<void> {
// 게시글 존재 + 작성자 확인
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<void> {
// 게시글 존재 + 작성자 확인
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]
)
}