관리자 기능과 태그 표시 설정 추가

This commit is contained in:
2026-05-01 18:00:22 +09:00
parent 237eb2990f
commit 787747aa7f
51 changed files with 2261 additions and 128 deletions

View File

@@ -50,9 +50,63 @@ const mapTagRow = (row) => ({
id: row.id,
name: row.name,
slug: row.slug,
description: row.description
description: row.description,
sortOrder: row.sort_order,
color: row.color
})
/**
* 태그 슬러그 목록 정규화
* @param {Array<string>} tags - 태그 슬러그 목록
* @returns {Array<string>} 정규화된 태그 슬러그 목록
*/
const normalizeTagSlugs = (tags = []) => [...new Set(tags
.map((tag) => String(tag).trim().toLowerCase())
.filter(Boolean))]
/**
* 태그 슬러그를 태그명으로 변환
* @param {string} slug - 태그 슬러그
* @returns {string} 태그명
*/
const getTagNameFromSlug = (slug) => slug
.split('-')
.filter(Boolean)
.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
.join(' ')
/**
* 게시물 태그 연결 저장
* @param {Function} sql - PostgreSQL 트랜잭션 클라이언트
* @param {string} postId - 게시물 ID
* @param {Array<string>} tags - 태그 슬러그 목록
* @returns {Promise<void>} 저장 결과
*/
const syncPostTags = async (sql, postId, tags) => {
const tagSlugs = normalizeTagSlugs(tags)
await sql`
DELETE FROM post_tags
WHERE post_id = ${postId}
`
for (const slug of tagSlugs) {
const tagRows = await sql`
INSERT INTO tags (name, slug)
VALUES (${getTagNameFromSlug(slug)}, ${slug})
ON CONFLICT (slug) DO UPDATE
SET updated_at = now()
RETURNING id
`
await sql`
INSERT INTO post_tags (post_id, tag_id)
VALUES (${postId}, ${tagRows[0].id})
ON CONFLICT DO NOTHING
`
}
}
/**
* 공개 게시물 목록 조회
* @returns {Promise<Array>} 게시물 목록
@@ -79,6 +133,163 @@ export const listPosts = async () => {
return rows.map(mapPostRow)
}
/**
* 관리자 게시물 목록 조회
* @returns {Promise<Array>} 관리자 게시물 목록
*/
export const listAdminPosts = async () => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePosts()
}
const rows = await sql`
SELECT
posts.*,
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id
LEFT JOIN tags ON tags.id = post_tags.tag_id
GROUP BY posts.id
ORDER BY posts.updated_at DESC
`
return rows.map(mapPostRow)
}
/**
* 관리자 게시물 상세 조회
* @param {string} id - 게시물 ID
* @returns {Promise<Object | null>} 관리자 게시물 상세
*/
export const getAdminPostById = async (id) => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePosts().find((post) => post.id === id) || null
}
const rows = await sql`
SELECT
posts.*,
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id
LEFT JOIN tags ON tags.id = post_tags.tag_id
WHERE posts.id = ${id}
GROUP BY posts.id
LIMIT 1
`
return rows[0] ? mapPostRow(rows[0]) : null
}
/**
* 관리자 게시물 생성
* @param {Object} input - 게시물 입력값
* @returns {Promise<Object>} 생성된 게시물
*/
export const createAdminPost = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql.begin(async (transaction) => {
const insertedRows = await transaction`
INSERT INTO posts (
title,
slug,
content,
excerpt,
featured_image,
status,
published_at
)
VALUES (
${input.title},
${input.slug},
${input.content},
${input.excerpt},
${input.featuredImage},
${input.status},
${input.publishedAt}
)
RETURNING *
`
await syncPostTags(transaction, insertedRows[0].id, input.tags)
return insertedRows
})
return getAdminPostById(rows[0].id)
}
/**
* 관리자 게시물 수정
* @param {string} id - 게시물 ID
* @param {Object} input - 게시물 입력값
* @returns {Promise<Object | null>} 수정된 게시물
*/
export const updateAdminPost = async (id, input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql.begin(async (transaction) => {
const updatedRows = await transaction`
UPDATE posts
SET
title = ${input.title},
slug = ${input.slug},
content = ${input.content},
excerpt = ${input.excerpt},
featured_image = ${input.featuredImage},
status = ${input.status},
published_at = ${input.publishedAt},
updated_at = now()
WHERE id = ${id}
RETURNING *
`
if (!updatedRows[0]) {
return []
}
await syncPostTags(transaction, id, input.tags)
return updatedRows
})
return rows[0] ? getAdminPostById(rows[0].id) : null
}
/**
* 관리자 게시물 삭제
* @param {string} id - 게시물 ID
* @returns {Promise<boolean>} 삭제 여부
*/
export const deleteAdminPost = async (id) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
DELETE FROM posts
WHERE id = ${id}
RETURNING id
`
return Boolean(rows[0])
}
/**
* 공개 게시물 상세 조회
* @param {string} slug - 게시물 슬러그
@@ -163,8 +374,101 @@ export const listTags = async () => {
const rows = await sql`
SELECT *
FROM tags
ORDER BY name ASC
ORDER BY sort_order ASC, name ASC
`
return rows.map(mapTagRow)
}
/**
* 관리자 태그 상세 조회
* @param {string} id - 태그 ID
* @returns {Promise<Object | null>} 태그 상세
*/
export const getAdminTagById = async (id) => {
const sql = getPostgresClient()
if (!sql) {
return getSampleTags().find((tag) => tag.id === id) || null
}
const rows = await sql`
SELECT *
FROM tags
WHERE id = ${id}
LIMIT 1
`
return rows[0] ? mapTagRow(rows[0]) : null
}
/**
* 관리자 태그 생성
* @param {Object} input - 태그 입력값
* @returns {Promise<Object>} 생성된 태그
*/
export const createAdminTag = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
INSERT INTO tags (name, slug, description, sort_order, color)
VALUES (${input.name}, ${input.slug}, ${input.description}, ${input.sortOrder}, ${input.color})
RETURNING *
`
return mapTagRow(rows[0])
}
/**
* 관리자 태그 수정
* @param {string} id - 태그 ID
* @param {Object} input - 태그 입력값
* @returns {Promise<Object | null>} 수정된 태그
*/
export const updateAdminTag = async (id, input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
UPDATE tags
SET
name = ${input.name},
slug = ${input.slug},
description = ${input.description},
sort_order = ${input.sortOrder},
color = ${input.color},
updated_at = now()
WHERE id = ${id}
RETURNING *
`
return rows[0] ? mapTagRow(rows[0]) : null
}
/**
* 관리자 태그 삭제
* @param {string} id - 태그 ID
* @returns {Promise<boolean>} 삭제 여부
*/
export const deleteAdminTag = async (id) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
DELETE FROM tags
WHERE id = ${id}
RETURNING id
`
return Boolean(rows[0])
}