import { getSamplePageBySlug, getSamplePages, getSamplePostBySlug, getSamplePosts, getSampleTags } from '../utils/sample-content' import { getDefaultNavigationItems } from '../utils/navigation-items' import { buildPublicPrimaryTree, orderNavigationItemsForInsert } from '../utils/navigation-tree' import { getDefaultSiteSettings } from '../utils/site-settings' import { toAdminPostFormTitle } from '../../lib/admin-post-title.js' import { normalizeSignupBlockedUsernames, parseSignupBlockedUsernamesFromDb } from '../../lib/signup-blocked-usernames.js' import { getPostgresClient } from './postgres-client' /** * 게시물 행을 API 응답 구조로 변환 * @param {Object} row - 게시물 행 * @returns {Object} 게시물 응답 */ const mapPostRow = (row) => ({ id: row.id, title: row.title, slug: row.slug, authorId: row.author_id || null, content: row.content, excerpt: row.excerpt, featuredImage: row.featured_image, isFeatured: Boolean(row.is_featured), commentCount: Number(row.comment_count || 0), seoTitle: row.seo_title || '', seoDescription: row.seo_description || '', canonicalUrl: row.canonical_url || '', noindex: Boolean(row.noindex), ogImage: row.og_image || null, 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 mapAdminPostRow = (row) => ({ ...mapPostRow(row), title: toAdminPostFormTitle(row.title) }) /** * 고정 페이지 행을 API 응답 구조로 변환 * @param {Object} row - 고정 페이지 행 * @returns {Object} 고정 페이지 응답 */ const mapPageRow = (row) => ({ id: row.id, title: row.title, slug: row.slug, content: row.content, renderMode: row.render_mode || 'markdown', status: row.status || 'published', 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, tagType: row.tag_type || 'managed', postCount: Number(row.post_count || 0), lastUsedAt: row.last_used_at ? row.last_used_at.toISOString() : null, updatedAt: row.updated_at ? row.updated_at.toISOString() : null }) /** * 사이트 설정 행을 API 응답 구조로 변환 * @param {Object} row - 사이트 설정 행 * @returns {Object} 사이트 설정 응답 */ const mapSiteSettingsRow = (row) => ({ title: row.title, description: row.description, siteUrl: row.site_url, logoText: row.logo_text, logoUrl: row.logo_url || '', faviconUrl: row.favicon_url || '', copyrightText: row.copyright_text, showPostUpdatedAt: Boolean(row.show_post_updated_at), homeCoverImageUrl: row.home_cover_image_url || '', homeCoverDarkImageUrl: row.home_cover_dark_image_url || '', homeCoverTitle: row.home_cover_title || '', homeCoverText: row.home_cover_text || '', announcementEnabled: Boolean(row.announcement_enabled), announcementText: row.announcement_text || '', announcementUrl: row.announcement_url || '', announcementBackgroundColor: row.announcement_background_color || '#15171a', signupBlockedUsernames: parseSignupBlockedUsernamesFromDb(row.signup_blocked_usernames), adsTxt: row.ads_txt || '', customHeadCode: row.custom_head_code || '', customFooterCode: row.custom_footer_code || '', updatedAt: row.updated_at.toISOString() }) /** * 네비게이션 행을 API 응답 구조로 변환 * @param {Object} row - 네비게이션 행 * @returns {Object} 네비게이션 응답 */ const mapNavigationItemRow = (row) => ({ id: row.id, label: row.label, url: row.url, descriptionText: row.description_text || '', thumbnailUrl: row.thumbnail_url || '', location: row.location, sortOrder: row.sort_order, isVisible: row.is_visible, parentId: row.parent_id ?? null, isFolder: Boolean(row.is_folder), createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString() }) /** * 태그 슬러그 목록 정규화 * @param {Array} tags - 태그 슬러그 목록 * @returns {Array} 정규화된 태그 슬러그 목록 */ const normalizeTagSlugs = (tags = []) => { const seen = new Set() const out = [] for (const tag of tags) { const trimmed = String(tag).trim().normalize('NFC') if (!trimmed) { continue } const dedupeKey = trimmed.toLowerCase() if (seen.has(dedupeKey)) { continue } seen.add(dedupeKey) out.push(trimmed) } return out } /** * 태그 슬러그를 표시용 태그명으로 변환 * @param {string} slug - 태그 슬러그 * @returns {string} 태그명 */ const getTagNameFromSlug = (slug) => { const trimmed = String(slug).trim() if (!trimmed) { return '' } if (/[가-힣]/.test(trimmed)) { return trimmed.split('-').filter(Boolean).join(' ') } return trimmed .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, tag_type, sort_order) VALUES (${getTagNameFromSlug(slug)}, ${slug}, 'general', 0) 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 ` } } /** * 공개 게시물 목록 조회 * @param {{ includeMembership?: boolean }} [options] - VIP 전용 글 포함 여부 * @returns {Promise} 게시물 목록 */ export const listPosts = async ({ includeMembership = false } = {}) => { const sql = getPostgresClient() if (!sql) { return getSamplePosts() } const rows = await sql` SELECT posts.*, ( SELECT COUNT(*)::int FROM comments WHERE comments.post_id = posts.id AND comments.status = 'published' ) AS comment_count, 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' OR (${includeMembership} = true AND posts.status = 'members') ) AND ( posts.status = 'members' OR posts.published_at IS NULL OR posts.published_at <= now() ) 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.*, ( SELECT COUNT(*)::int FROM comments WHERE comments.post_id = posts.id AND comments.status = 'published' ) AS comment_count, 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 COALESCE(posts.published_at, posts.updated_at) DESC ` return rows.map(mapAdminPostRow) } /** * 관리자 게시물 상세 조회 * @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.*, ( SELECT COUNT(*)::int FROM comments WHERE comments.post_id = posts.id AND comments.status = 'published' ) AS comment_count, 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] ? mapAdminPostRow(rows[0]) : null } /** * 관리자 게시물 생성 * @param {Object} input - 게시물 입력값 * @param {string} authorId - 작성자 회원 ID * @returns {Promise} 생성된 게시물 */ export const createAdminPost = async (input, authorId) => { 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, author_id, content, excerpt, featured_image, is_featured, seo_title, seo_description, canonical_url, noindex, og_image, status, published_at ) VALUES ( ${input.title}, ${input.slug}, ${authorId || null}, ${input.content}, ${input.excerpt}, ${input.featuredImage}, ${input.isFeatured}, ${input.seoTitle}, ${input.seoDescription}, ${input.canonicalUrl}, ${input.noindex}, ${input.ogImage}, ${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 - 게시물 입력값 * @param {string} editorId - 수정자 회원 ID * @returns {Promise} 수정된 게시물 */ export const updateAdminPost = async (id, input, editorId) => { 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}, author_id = COALESCE(author_id, ${editorId || null}), content = ${input.content}, excerpt = ${input.excerpt}, featured_image = ${input.featuredImage}, is_featured = ${input.isFeatured}, seo_title = ${input.seoTitle}, seo_description = ${input.seoDescription}, canonical_url = ${input.canonicalUrl}, noindex = ${input.noindex}, og_image = ${input.ogImage}, 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 - 게시물 슬러그 * @param {{ includeMembership?: boolean }} [options] - VIP 전용 글 포함 여부 * @returns {Promise} 게시물 상세 */ export const getPostBySlug = async (slug, { includeMembership = false } = {}) => { const sql = getPostgresClient() if (!sql) { return getSamplePostBySlug(slug) } const rows = await sql` SELECT posts.*, ( SELECT COUNT(*)::int FROM comments WHERE comments.post_id = posts.id AND comments.status = 'published' ) AS comment_count, 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' OR (${includeMembership} = true AND posts.status = 'members') ) AND ( posts.status = 'members' OR posts.published_at IS NULL OR posts.published_at <= now() ) 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 WHERE status = 'published' ORDER BY created_at DESC ` return rows.map(mapPageRow) } /** * 관리자 고정 페이지 목록 조회 * @returns {Promise} 관리자 고정 페이지 목록 */ export const listAdminPages = async () => { const sql = getPostgresClient() if (!sql) { return getSamplePages() } const rows = await sql` SELECT * FROM pages ORDER BY created_at DESC ` return rows.map(mapPageRow) } /** * 관리자 고정 페이지 상세 조회 * @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, render_mode, featured_image, status ) VALUES ( ${input.title}, ${input.slug}, ${input.content}, ${input.renderMode}, ${input.featuredImage}, ${input.status} ) 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}, render_mode = ${input.renderMode}, featured_image = ${input.featuredImage}, status = ${input.status}, 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} AND status = 'published' LIMIT 1 ` return rows[0] ? mapPageRow(rows[0]) : null } /** * 공개 태그 목록 조회 * @returns {Promise} 태그 목록 */ export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => { const sql = getPostgresClient() const trimmedSearchQuery = String(searchQuery || '').trim().toLowerCase() const resolvedLimit = Number.isInteger(limit) && limit > 0 ? limit : null if (!sql) { const sampleTags = getSampleTags().map((tag) => ({ ...tag, tagType: 'managed', postCount: 0, lastUsedAt: null, updatedAt: null })) let filteredTags = sampleTags if (tagType) { filteredTags = filteredTags.filter((tag) => tag.tagType === tagType) } if (trimmedSearchQuery) { filteredTags = filteredTags.filter((tag) => tag.name.toLowerCase().includes(trimmedSearchQuery) || tag.slug.toLowerCase().includes(trimmedSearchQuery) ) } return resolvedLimit ? filteredTags.slice(0, resolvedLimit) : filteredTags } const rows = await sql` SELECT tags.*, COUNT(post_tags.post_id)::int AS post_count, MAX(posts.updated_at) AS last_used_at FROM tags LEFT JOIN post_tags ON post_tags.tag_id = tags.id LEFT JOIN posts ON posts.id = post_tags.post_id WHERE (${tagType || null}::text IS NULL OR tag_type = ${tagType || null}) AND ( ${trimmedSearchQuery || null}::text IS NULL OR strpos(lower(tags.name), ${trimmedSearchQuery || ''}) > 0 OR strpos(lower(tags.slug), ${trimmedSearchQuery || ''}) > 0 ) GROUP BY tags.id ORDER BY CASE tag_type WHEN 'managed' THEN 0 ELSE 1 END ASC, sort_order ASC, MAX(posts.updated_at) DESC NULLS LAST, tags.updated_at DESC, name ASC LIMIT ${resolvedLimit || 1000} ` return rows.map(mapTagRow) } /** * 관리자 태그 목록 조회 * @param {Object} options - 조회 옵션 * @returns {Promise} 관리자 태그 목록 */ export const listAdminTags = async (options = {}) => listTags(options) const SEARCH_TAG_LIMIT = 12 const SEARCH_POST_LIMIT = 12 const SEARCH_POST_CANDIDATE_LIMIT = 48 /** * 공개 검색에서 업로드 경로/파일명 같은 노이즈를 제거한다. * - 마크다운 이미지 문법 `![](...)` 제거 * - 업로드 경로(`/uploads/...`) 제거 * @param {string} value - 원문 * @returns {string} 정규화된 문자열 */ const normalizeSearchContent = (value) => String(value || '') .replace(/!\[[^\]]*]\([^)]*\)/g, ' ') .replace(/\/uploads\/[^\s)"]+/g, ' ') .replace(/\s+/g, ' ') .trim() /** * 공개 검색: 태그·게시물 제목·요약·본문에서 부분 일치(대소문자 무시) * @param {string} rawQuery - 검색어 * @returns {Promise<{ tags: Array<{ name: string, slug: string }>, posts: Array<{ slug: string, title: string, excerpt: string }> }>} 태그·게시물 요약 결과 */ export const searchPublicContent = async (rawQuery) => { const q = String(rawQuery || '').trim() if (!q) { return { tags: [], posts: [] } } const sql = getPostgresClient() if (!sql) { const needle = q.toLowerCase() const posts = getSamplePosts() .filter((post) => { const hay = normalizeSearchContent(`${post.title}\n${post.excerpt || ''}\n${post.content || ''}`).toLowerCase() return hay.includes(needle) }) .slice(0, SEARCH_POST_LIMIT) .map((post) => ({ slug: post.slug, title: post.title, excerpt: post.excerpt || '' })) const tags = getSampleTags() .filter((tag) => { const hay = `${tag.name}\n${tag.slug}`.toLowerCase() return hay.includes(needle) }) .slice(0, SEARCH_TAG_LIMIT) .map((tag) => ({ name: tag.name, slug: tag.slug })) return { tags, posts } } const tagRows = await sql` SELECT name, slug FROM tags WHERE position(lower(${q}) in lower(name)) > 0 OR position(lower(${q}) in lower(slug)) > 0 ORDER BY sort_order ASC, name ASC LIMIT ${SEARCH_TAG_LIMIT} ` const postRows = await sql` SELECT posts.slug, posts.title, posts.excerpt, posts.content FROM posts WHERE posts.status = 'published' AND (posts.published_at IS NULL OR posts.published_at <= now()) AND ( position(lower(${q}) in lower(posts.title)) > 0 OR position(lower(${q}) in lower(coalesce(posts.excerpt, ''))) > 0 OR position(lower(${q}) in lower(posts.content)) > 0 ) ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC LIMIT ${SEARCH_POST_CANDIDATE_LIMIT} ` const needle = q.toLowerCase() const posts = postRows .filter((row) => { const hay = normalizeSearchContent(`${row.title}\n${row.excerpt || ''}\n${row.content || ''}`).toLowerCase() return hay.includes(needle) }) .slice(0, SEARCH_POST_LIMIT) return { tags: tagRows.map((row) => ({ name: row.name, slug: row.slug })), posts: posts.map((row) => ({ slug: row.slug, title: row.title, excerpt: row.excerpt || '' })) } } /** * 사이트 설정 조회 * @returns {Promise} 사이트 설정 */ export const getSiteSettings = async () => { const sql = getPostgresClient() if (!sql) { return getDefaultSiteSettings() } const rows = await sql` SELECT * FROM site_settings WHERE id = 1 LIMIT 1 ` return rows[0] ? mapSiteSettingsRow(rows[0]) : getDefaultSiteSettings() } /** * 관리자 사이트 설정 수정 * @param {Object} input - 사이트 설정 입력값 * @returns {Promise} 수정된 사이트 설정 */ export const updateSiteSettings = async (input) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const rows = await sql` INSERT INTO site_settings ( id, title, description, site_url, logo_text, logo_url, favicon_url, copyright_text, show_post_updated_at, home_cover_image_url, home_cover_dark_image_url, home_cover_title, home_cover_text, announcement_enabled, announcement_text, announcement_url, announcement_background_color, signup_blocked_usernames, ads_txt, custom_head_code, custom_footer_code, updated_at ) VALUES ( 1, ${input.title}, ${input.description}, ${input.siteUrl}, ${input.logoText || '井'}, ${input.logoUrl || ''}, ${input.faviconUrl || ''}, ${input.copyrightText}, ${input.showPostUpdatedAt ? true : false}, ${input.homeCoverImageUrl || ''}, ${input.homeCoverDarkImageUrl || ''}, ${input.homeCoverTitle || ''}, ${input.homeCoverText || ''}, ${input.announcementEnabled ? true : false}, ${input.announcementText || ''}, ${input.announcementUrl || ''}, ${input.announcementBackgroundColor || '#15171a'}, ${JSON.stringify(normalizeSignupBlockedUsernames(input.signupBlockedUsernames))}, ${input.adsTxt || ''}, ${input.customHeadCode || ''}, ${input.customFooterCode || ''}, now() ) ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, site_url = EXCLUDED.site_url, logo_text = EXCLUDED.logo_text, logo_url = EXCLUDED.logo_url, favicon_url = EXCLUDED.favicon_url, copyright_text = EXCLUDED.copyright_text, show_post_updated_at = EXCLUDED.show_post_updated_at, home_cover_image_url = EXCLUDED.home_cover_image_url, home_cover_dark_image_url = EXCLUDED.home_cover_dark_image_url, home_cover_title = EXCLUDED.home_cover_title, home_cover_text = EXCLUDED.home_cover_text, announcement_enabled = EXCLUDED.announcement_enabled, announcement_text = EXCLUDED.announcement_text, announcement_url = EXCLUDED.announcement_url, announcement_background_color = EXCLUDED.announcement_background_color, signup_blocked_usernames = EXCLUDED.signup_blocked_usernames, ads_txt = EXCLUDED.ads_txt, custom_head_code = EXCLUDED.custom_head_code, custom_footer_code = EXCLUDED.custom_footer_code, updated_at = now() RETURNING * ` return mapSiteSettingsRow(rows[0]) } /** * 사이트 로고 URL을 수정한다. * @param {{ logoUrl: string, faviconUrl: string }} input - 로고 URL * @returns {Promise} 수정된 사이트 설정 */ export const updateSiteLogo = async (input) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const rows = await sql` INSERT INTO site_settings ( id, logo_url, favicon_url, updated_at ) VALUES ( 1, ${input.logoUrl}, ${input.faviconUrl}, now() ) ON CONFLICT (id) DO UPDATE SET logo_url = EXCLUDED.logo_url, favicon_url = EXCLUDED.favicon_url, updated_at = now() RETURNING * ` return mapSiteSettingsRow(rows[0]) } /** * 메인 화면 커버 이미지 URL을 수정한다. * @param {{ homeCoverImageUrl: string }} input - 커버 이미지 URL * @returns {Promise} 수정된 사이트 설정 */ export const updateSiteHomeCoverImage = async (input) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const rows = await sql` INSERT INTO site_settings ( id, home_cover_image_url, updated_at ) VALUES ( 1, ${input.homeCoverImageUrl || ''}, now() ) ON CONFLICT (id) DO UPDATE SET home_cover_image_url = EXCLUDED.home_cover_image_url, updated_at = now() RETURNING * ` return mapSiteSettingsRow(rows[0]) } /** * 네비게이션 항목 목록 조회 * @param {Object} options - 조회 옵션 * @param {boolean} options.visibleOnly - 표시 항목만 조회할지 여부 * @returns {Promise} 네비게이션 항목 목록 */ export const listNavigationItems = async ({ visibleOnly = false } = {}) => { const sql = getPostgresClient() if (!sql) { return getDefaultNavigationItems() .filter((item) => !visibleOnly || item.isVisible) } const rows = visibleOnly ? await sql` SELECT * FROM navigation_items WHERE is_visible = true ORDER BY location ASC, sort_order ASC, label ASC ` : await sql` SELECT * FROM navigation_items ORDER BY location ASC, sort_order ASC, label ASC ` return rows.map(mapNavigationItemRow) } /** * 공개 네비게이션 조회 * @returns {Promise<{primary: Array, footer: Array, recommended: Array}>} 위치별 공개 네비게이션 */ export const getPublicNavigation = async () => { const flat = await listNavigationItems({ visibleOnly: true }) const primaryFlat = flat.filter((item) => item.location === 'primary') const footerFlat = flat .filter((item) => item.location === 'footer' && !item.parentId) .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) const recommendedFlat = flat .filter((item) => item.location === 'recommended' && !item.parentId) .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) return { primary: buildPublicPrimaryTree(primaryFlat), footer: footerFlat.map((item) => ({ id: item.id, label: item.label, url: item.url, isVisible: item.isVisible })), recommended: recommendedFlat.map((item) => ({ id: item.id, label: item.label, url: item.url, descriptionText: item.descriptionText, thumbnailUrl: item.thumbnailUrl, isVisible: item.isVisible })) } } /** * 관리자 네비게이션 항목 일괄 저장 * @param {Array} items - 저장할 네비게이션 항목 목록 * @returns {Promise} 저장된 네비게이션 항목 목록 */ export const updateNavigationItems = async (items) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } await sql.begin(async (transaction) => { await transaction` DELETE FROM navigation_items ` const ordered = orderNavigationItemsForInsert(items) for (const item of ordered) { await transaction` INSERT INTO navigation_items ( id, label, url, description_text, thumbnail_url, location, sort_order, is_visible, parent_id, is_folder ) VALUES ( ${item.id}, ${item.label}, ${item.url}, ${item.descriptionText || ''}, ${item.thumbnailUrl || ''}, ${item.location}, ${item.sortOrder}, ${item.isVisible}, ${item.parentId ?? null}, ${Boolean(item.isFolder)} ) ` } }) return listNavigationItems() } /** * 관리자 태그 상세 조회 * @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, tag_type) VALUES (${input.name}, ${input.slug}, ${input.description}, ${input.sortOrder}, ${input.color}, ${input.tagType}) 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}, tag_type = ${input.tagType}, updated_at = now() WHERE id = ${id} RETURNING * ` return rows[0] ? mapTagRow(rows[0]) : null } /** * 관리자 관리용 태그 순서를 일괄 갱신 * @param {Array} tagIds - 정렬된 태그 ID 목록 * @returns {Promise} 갱신된 태그 목록 */ export const reorderManagedTags = async (tagIds) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } await sql.begin(async (transaction) => { for (let index = 0; index < tagIds.length; index += 1) { const tagId = tagIds[index] await transaction` UPDATE tags SET sort_order = ${(index + 1) * 10}, updated_at = now() WHERE id = ${tagId} AND tag_type = 'managed' ` } }) return listTags({ tagType: 'managed' }) } /** * 관리자 태그 삭제 * @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]) }