- 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>
416 lines
12 KiB
TypeScript
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]
|
|
)
|
|
}
|