import { getSamplePageBySlug, getSamplePages, getSamplePostBySlug, getSamplePosts, getSampleTags } from '../utils/sample-content' import { getPostgresClient } from './postgres-client' /** * 게시물 행을 API 응답 구조로 변환 * @param {Object} row - 게시물 행 * @returns {Object} 게시물 응답 */ const mapPostRow = (row) => ({ id: row.id, title: row.title, slug: row.slug, content: row.content, excerpt: row.excerpt, featuredImage: row.featured_image, status: row.status, publishedAt: row.published_at ? row.published_at.toISOString() : null, createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString(), tags: row.tags || [] }) /** * 고정 페이지 행을 API 응답 구조로 변환 * @param {Object} row - 고정 페이지 행 * @returns {Object} 고정 페이지 응답 */ const mapPageRow = (row) => ({ id: row.id, title: row.title, slug: row.slug, content: row.content, featuredImage: row.featured_image, createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString() }) /** * 태그 행을 API 응답 구조로 변환 * @param {Object} row - 태그 행 * @returns {Object} 태그 응답 */ const mapTagRow = (row) => ({ id: row.id, name: row.name, slug: row.slug, description: row.description, sortOrder: row.sort_order, color: row.color }) /** * 태그 슬러그 목록 정규화 * @param {Array} tags - 태그 슬러그 목록 * @returns {Array} 정규화된 태그 슬러그 목록 */ 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} tags - 태그 슬러그 목록 * @returns {Promise} 저장 결과 */ 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} 게시물 목록 */ export const listPosts = 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 WHERE posts.status = 'published' GROUP BY posts.id ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC ` return rows.map(mapPostRow) } /** * 관리자 게시물 목록 조회 * @returns {Promise} 관리자 게시물 목록 */ 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} 관리자 게시물 상세 */ 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} 생성된 게시물 */ 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} 수정된 게시물 */ 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} 삭제 여부 */ 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 - 게시물 슬러그 * @returns {Promise} 게시물 상세 */ export const getPostBySlug = async (slug) => { const sql = getPostgresClient() if (!sql) { return getSamplePostBySlug(slug) } 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.slug = ${slug} AND posts.status = 'published' GROUP BY posts.id LIMIT 1 ` return rows[0] ? mapPostRow(rows[0]) : null } /** * 공개 고정 페이지 목록 조회 * @returns {Promise} 고정 페이지 목록 */ export const listPages = async () => { const sql = getPostgresClient() if (!sql) { return getSamplePages() } const rows = await sql` SELECT * FROM pages ORDER BY created_at DESC ` return rows.map(mapPageRow) } /** * 관리자 고정 페이지 목록 조회 * @returns {Promise} 관리자 고정 페이지 목록 */ export const listAdminPages = async () => listPages() /** * 관리자 고정 페이지 상세 조회 * @param {string} id - 페이지 ID * @returns {Promise} 관리자 고정 페이지 상세 */ export const getAdminPageById = async (id) => { const sql = getPostgresClient() if (!sql) { return getSamplePages().find((page) => page.id === id) || null } const rows = await sql` SELECT * FROM pages WHERE id = ${id} LIMIT 1 ` return rows[0] ? mapPageRow(rows[0]) : null } /** * 관리자 고정 페이지 생성 * @param {Object} input - 페이지 입력값 * @returns {Promise} 생성된 페이지 */ export const createAdminPage = async (input) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const rows = await sql` INSERT INTO pages ( title, slug, content, featured_image ) VALUES ( ${input.title}, ${input.slug}, ${input.content}, ${input.featuredImage} ) RETURNING * ` return mapPageRow(rows[0]) } /** * 관리자 고정 페이지 수정 * @param {string} id - 페이지 ID * @param {Object} input - 페이지 입력값 * @returns {Promise} 수정된 페이지 */ export const updateAdminPage = async (id, input) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const rows = await sql` UPDATE pages SET title = ${input.title}, slug = ${input.slug}, content = ${input.content}, featured_image = ${input.featuredImage}, updated_at = now() WHERE id = ${id} RETURNING * ` return rows[0] ? mapPageRow(rows[0]) : null } /** * 관리자 고정 페이지 삭제 * @param {string} id - 페이지 ID * @returns {Promise} 삭제 여부 */ export const deleteAdminPage = async (id) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const rows = await sql` DELETE FROM pages WHERE id = ${id} RETURNING id ` return Boolean(rows[0]) } /** * 공개 고정 페이지 상세 조회 * @param {string} slug - 페이지 슬러그 * @returns {Promise} 고정 페이지 상세 */ export const getPageBySlug = async (slug) => { const sql = getPostgresClient() if (!sql) { return getSamplePageBySlug(slug) } const rows = await sql` SELECT * FROM pages WHERE slug = ${slug} LIMIT 1 ` return rows[0] ? mapPageRow(rows[0]) : null } /** * 공개 태그 목록 조회 * @returns {Promise} 태그 목록 */ export const listTags = async () => { const sql = getPostgresClient() if (!sql) { return getSampleTags() } const rows = await sql` SELECT * FROM tags ORDER BY sort_order ASC, name ASC ` return rows.map(mapTagRow) } /** * 관리자 태그 상세 조회 * @param {string} id - 태그 ID * @returns {Promise} 태그 상세 */ 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} 생성된 태그 */ 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} 수정된 태그 */ 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} 삭제 여부 */ 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]) }