- 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>
244 lines
6.6 KiB
TypeScript
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]
|
|
)
|
|
}
|