wing-ops/backend/src/board/boardService.ts
Nan Kyung Lee ce80e620c1 feat(admin): 관리자 화면 고도화 — 사용자/권한/게시판/선박신호 패널
- UsersPanel: 테이블+페이징+등록모달+상세모달(비밀번호초기화/잠금해제)
- PermissionsPanel: 사용자별 역할 할당 탭 추가
- BoardMgmtPanel: 공지사항/게시판/QNA 관리자 일괄 삭제
- VesselSignalPanel: VTS/VTS-AIS/V-PASS/E-NAVI/S&P AIS 타임라인 모니터링
- AdminSidebar/AdminPlaceholder/adminMenuConfig 신규
- 권한 미들웨어 부모 리소스 fallback 로직 추가
- 조직 목록 API, 관리자 삭제 API 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:30:55 +09:00

416 lines
12 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
)
}
// ============================================================
// 매뉴얼 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<string, unknown>): 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<ManualItem[]> {
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<string, unknown>) => 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<void> {
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<void> {
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<void> {
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<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]
)
}
/** 관리자 전용 삭제 — 소유자 검증 없이 논리 삭제 */
export async function adminDeletePost(postSn: number): Promise<void> {
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]
)
}