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 { DEFAULT_ANNOUNCEMENT_ALIGNMENT, DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR, normalizeAnnouncementAlignment, normalizeAnnouncementBackgroundColor } from '../../lib/announcement-bar.js' import { DEFAULT_BRAND_COLOR, normalizeBrandColor } from '../../lib/brand-color.js' import { normalizeSignupBlockedUsernames, parseSignupBlockedUsernamesFromDb } from '../../lib/signup-blocked-usernames.js' import { normalizePostTagLimit } from '../../lib/post-tag-limit.js' import { DEFAULT_FEATURED_POST_LIMIT, DEFAULT_HOME_LATEST_POST_LIMIT, DEFAULT_PAGE_SECTION_ITEM_LIMIT, DEFAULT_POST_LIST_PAGE_SIZE, DEFAULT_TAG_POST_LIST_PAGE_SIZE, normalizeDisplayCount } from '../../lib/display-count-settings.js' import { normalizeUploadMaxFileCount, normalizeUploadMaxTotalSize } from '../../lib/upload-size-limit.js' import { normalizeSocialLinks } from '../../lib/social-links.js' import { createPageCoverThumbnailForImageUrl, createPostBodyThumbnailsForContent, createPostThumbnailForImageUrl, getExistingPageCoverThumbnailUrl, getExistingPostThumbnailUrl } from '../utils/post-thumbnail-image.js' import { getPostgresClient } from './postgres-client' import { replaceMediaReferences } from './media-catalog-repository' import { createPostSummary } from '../../composables/createPostSummary.js' /** * 콘텐츠 저장 성공을 방해하지 않고 미디어 참조를 갱신한다. * @param {string} type - 참조 종류 * @param {string} id - 콘텐츠 id * @param {Object} input - 저장 입력 * @returns {Promise} */ const syncContentMediaReferences = async (type, id, input) => { try { await replaceMediaReferences(type, id, { content: input.content, featuredImage: input.featuredImage, ogImage: input.ogImage }, { title: input.title, slug: input.slug, status: input.status }) } catch { console.warn('[media-catalog] 콘텐츠 참조 갱신 실패') } } /** * 사이트 설정 미디어 참조를 최선 노력 방식으로 갱신한다. * @param {Object} settings - 현재 사이트 설정 * @returns {Promise} */ const syncSiteSettingsMediaReferences = async (settings) => { try { await replaceMediaReferences('site_setting', 'site', { logoUrl: settings.logoUrl, faviconUrl: settings.faviconUrl, homeCoverImageUrl: settings.homeCoverImageUrl, homeCoverDarkImageUrl: settings.homeCoverDarkImageUrl }, { title: '사이트 설정', status: 'system' }) } catch { console.warn('[media-catalog] 사이트 설정 참조 갱신 실패') } } /** * 태그 미디어 참조를 최선 노력 방식으로 갱신한다. * @param {Object} tag - 현재 태그 * @returns {Promise} */ const syncTagMediaReferences = async (tag) => { try { await replaceMediaReferences('tag', tag.id, { headerImageUrl: tag.headerImageUrl }, { title: tag.name, slug: tag.slug, status: tag.tagType }) } catch { console.warn('[media-catalog] 태그 참조 갱신 실패') } } /** * 게시물 행을 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, featuredImageThumbnail: getExistingPostThumbnailUrl(row.featured_image), showFeaturedImage: Boolean(row.show_featured_image), isFeatured: Boolean(row.is_featured), adminPinned: Boolean(row.admin_pinned), commentCount: Number(row.comment_count || 0), totalViews: Number(row.total_views || 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 || [] }) /** * 공개 목록 게시물에서 본문을 제외하고 표시용 요약을 만든다. * @param {Object} post - 게시물 응답 * @returns {Object} 공개 목록 게시물 요약 */ const mapPublicPostSummary = (post) => { const { content, ...summary } = post return { ...summary, excerpt: createPostSummary(post.excerpt, content, { maxLength: 320, appendEllipsis: false }) } } /** * 관리자 API용 게시물 행 변환(제목 없음 플레이스홀더는 빈 문자열) * @param {Object} row - 게시물 행 * @returns {Object} 게시물 응답 */ const mapAdminPostRow = (row) => ({ ...mapPostRow(row), title: toAdminPostFormTitle(row.title) }) /** * 관리자 목록 API용 게시물 요약 행 변환 * @param {Object} row - 게시물 행 * @returns {Object} 게시물 요약 응답 */ const mapAdminPostSummaryRow = (row) => ({ id: row.id, title: toAdminPostFormTitle(row.title), slug: row.slug, authorId: row.author_id || null, excerpt: row.excerpt, featuredImage: row.featured_image, featuredImageThumbnail: getExistingPostThumbnailUrl(row.featured_image), showFeaturedImage: Boolean(row.show_featured_image), isFeatured: Boolean(row.is_featured), adminPinned: Boolean(row.admin_pinned), commentCount: Number(row.comment_count || 0), totalViews: Number(row.total_views || 0), status: row.status, publishedAt: row.published_at ? row.published_at.toISOString() : null, createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString(), backlinkCount: Number(row.backlinkCount || row.backlink_count || 0), tags: row.tags || [] }) /** * 정규식 특수 문자를 이스케이프한다. * @param {string} value - 원본 문자열 * @returns {string} 이스케이프된 문자열 */ const escapeRegExp = (value) => String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') /** * 게시물 공개 링크 참조 정규식을 생성한다. * @param {string} slug - 게시물 슬러그 * @returns {RegExp} 게시물 링크 정규식 */ const createPostLinkReferencePattern = (slug) => { return new RegExp(`(?:^|[\\s("'\\[])(?:https?:\\/\\/[^/\\s)"'<>]+)?\\/post\\/${escapeRegExp(slug)}(?=$|[\\s)"'<>?#/])`, 'gi') } /** * 본문에서 특정 게시물 링크 참조 개수를 계산한다. * @param {string} content - 게시물 본문 * @param {string} slug - 참조 대상 슬러그 * @returns {number} 참조 개수 */ const countPostLinkReferences = (content, slug) => { if (!slug) { return 0 } return [...String(content || '').matchAll(createPostLinkReferencePattern(slug))].length } /** * 대상 게시물별 백링크 콘텐츠 개수 맵을 생성한다. * @param {Array<{ id: string, slug: string }>} targetPosts - 백링크 개수를 계산할 게시물 목록 * @param {Array<{ id: string, sourceType?: 'post'|'page', content?: string }>} sourceItems - 링크 출처 콘텐츠 목록 * @returns {Map} 게시물 ID별 백링크 콘텐츠 개수 */ const createAdminPostBacklinkCountMap = (targetPosts, sourceItems) => { const countMap = new Map(targetPosts.map((post) => [post.id, 0])) for (const targetPost of targetPosts) { if (!targetPost?.id || !targetPost?.slug) { continue } const backlinkCount = sourceItems.filter((sourceItem) => { return !(sourceItem.sourceType === 'post' && sourceItem.id === targetPost.id) && countPostLinkReferences(sourceItem.content, targetPost.slug) > 0 }).length countMap.set(targetPost.id, backlinkCount) } return countMap } /** * 관리자 글 목록 대상의 백링크 콘텐츠 개수를 조회한다. * @param {Array<{ id: string, slug: string }>} targetPosts - 백링크 개수를 계산할 게시물 목록 * @returns {Promise>} 게시물 ID별 백링크 콘텐츠 개수 */ const getAdminPostBacklinkCountMap = async (targetPosts) => { const sql = getPostgresClient() if (!targetPosts.length) { return new Map() } if (!sql) { return createAdminPostBacklinkCountMap(targetPosts, [ ...getSamplePosts().map((post) => ({ ...post, sourceType: 'post' })), ...getSamplePages().map((page) => ({ ...page, sourceType: 'page' })) ]) } const sourceRows = await sql` SELECT id, 'post' AS source_type, content FROM posts WHERE content IS NOT NULL AND content <> '' UNION ALL SELECT id, 'page' AS source_type, content FROM pages WHERE content IS NOT NULL AND content <> '' ` return createAdminPostBacklinkCountMap(targetPosts, sourceRows.map((row) => ({ id: row.id, sourceType: row.source_type, content: row.content }))) } /** * 관리자 글 목록 페이지 표시 개수를 정규화한다. * @param {unknown} value - 요청 표시 개수 * @returns {number} 표시 개수 */ const normalizeAdminPostPageSize = (value) => { const pageSize = Number(value) || 50 if ([20, 50, 100].includes(pageSize)) { return pageSize } return 50 } /** * 관리자 글 목록 페이지 번호를 정규화한다. * @param {unknown} value - 요청 페이지 * @returns {number} 페이지 번호 */ const normalizeAdminPostPage = (value) => Math.max(1, Number(value) || 1) /** * 관리자 글 목록 상태 필터를 정규화한다. * @param {unknown} value - 요청 상태 필터 * @returns {'all'|'published'|'draft'|'scheduled'|'members'|'private'} 상태 필터 */ const normalizeAdminPostStatusFilter = (value) => { const status = String(value || 'all') return ['all', 'published', 'draft', 'scheduled', 'members', 'private'].includes(status) ? status : 'all' } /** * 관리자 글 목록 추천 필터를 정규화한다. * @param {unknown} value - 요청 추천 필터 * @returns {'all'|'featured'} 추천 필터 */ const normalizeAdminPostFeaturedFilter = (value) => String(value || 'all') === 'featured' ? 'featured' : 'all' /** * 관리자 글 목록 정렬을 정규화한다. * @param {unknown} value - 요청 정렬 * @returns {'newest'|'oldest'} 정렬 */ const normalizeAdminPostSortOrder = (value) => String(value || 'newest') === 'oldest' ? 'oldest' : 'newest' /** * 관리자 글 목록 검색 문자열을 정규화한다. * @param {unknown} value - 요청 검색어 * @returns {string} 검색어 */ const normalizeAdminPostSearchQuery = (value) => String(value || '').trim() /** * 고정 페이지 행을 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, listingImageThumbnail: getExistingPageCoverThumbnailUrl(row.featured_image), isListed: Boolean(row.is_listed), isFeatured: Boolean(row.is_featured), listingSection: row.listing_section || '', listingTitle: row.listing_title || '', listingDescription: row.listing_description || '', listingSortOrder: Number(row.listing_sort_order || 0), totalViews: Number(row.total_views || 0), createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString() }) /** * 페이지 섹션 행을 API 응답 구조로 변환 * @param {Object} row - 페이지 섹션 행 * @returns {Object} 페이지 섹션 응답 */ const mapPageSectionRow = (row) => ({ id: row.id, name: row.name, sortOrder: Number(row.sort_order || 0), pageCount: Number(row.page_count || 0), 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, headerImageUrl: row.header_image_url || '', headerTitle: row.header_title || '', headerText: row.header_text || '', 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, socialLinks: normalizeSocialLinks(row.social_links), showPostUpdatedAt: Boolean(row.show_post_updated_at), postTagLimit: normalizePostTagLimit(row.post_tag_limit), homeLatestPostLimit: normalizeDisplayCount(row.home_latest_post_limit, DEFAULT_HOME_LATEST_POST_LIMIT), postListPageSize: normalizeDisplayCount(row.post_list_page_size, DEFAULT_POST_LIST_PAGE_SIZE), tagPostListPageSize: normalizeDisplayCount(row.tag_post_list_page_size, DEFAULT_TAG_POST_LIST_PAGE_SIZE), featuredPostLimit: normalizeDisplayCount(row.featured_post_limit, DEFAULT_FEATURED_POST_LIMIT), pageSectionItemLimit: normalizeDisplayCount(row.page_section_item_limit, DEFAULT_PAGE_SECTION_ITEM_LIMIT), uploadMaxFileCount: normalizeUploadMaxFileCount(row.upload_max_file_count), uploadMaxTotalSize: normalizeUploadMaxTotalSize(row.upload_max_total_size), homeCoverImageUrl: row.home_cover_image_url || '', homeCoverDarkImageUrl: row.home_cover_dark_image_url || '', homeCoverTitle: row.home_cover_title || '', homeCoverText: row.home_cover_text || '', brandColor: normalizeBrandColor(row.brand_color || DEFAULT_BRAND_COLOR), announcementEnabled: Boolean(row.announcement_enabled), announcementText: row.announcement_text || '', announcementUrl: row.announcement_url || '', announcementBackgroundColor: normalizeAnnouncementBackgroundColor(row.announcement_background_color || DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR), announcementAlignment: normalizeAnnouncementAlignment(row.announcement_alignment || DEFAULT_ANNOUNCEMENT_ALIGNMENT), signupBlockedUsernames: parseSignupBlockedUsernamesFromDb(row.signup_blocked_usernames), adsTxt: row.ads_txt || '', customHeadCode: row.custom_head_code || '', customFooterCode: row.custom_footer_code || '', adHomeFeedCode: row.ad_home_feed_code || '', adHomeInfeedCode: row.ad_home_infeed_code || '', adSidebarCode: row.ad_sidebar_code || '', adPostSidebarCode: row.ad_post_sidebar_code || '', adPostTopCode: row.ad_post_top_code || '', adPostInArticleCode: row.ad_post_in_article_code || '', adPostBottomCode: row.ad_post_bottom_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 || '', cachedFaviconUrl: row.cached_favicon_url || '', faviconCachedAt: row.favicon_cached_at ? row.favicon_cached_at.toISOString() : '', 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 normalized = String(tag) .trim() .normalize('NFC') .toLowerCase() .replace(/[\\/]+/g, '-') .replace(/\s+/g, '-') .replace(/[^a-z0-9가-힣-]+/g, '') .replace(/-+/g, '-') .replace(/^-|-$/g, '') if (!normalized) { continue } const dedupeKey = normalized.toLowerCase() if (seen.has(dedupeKey)) { continue } seen.add(dedupeKey) out.push(normalized) } 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 {string} value - 원본 표시 이름 * @returns {string} 표시 이름 */ const normalizeTagDisplayName = (value) => String(value || '') .normalize('NFC') .trim() .replace(/\s*\/\s*/g, '/') .replace(/\s+/g, ' ') /** * 태그 슬러그에 대응하는 표시 이름을 반환한다. * @param {string} slug - 태그 슬러그 * @param {Object} tagNames - 태그 표시 이름 맵 * @returns {string} 태그명 */ const resolveTagName = (slug, tagNames = {}) => { const inputName = normalizeTagDisplayName(tagNames[slug]) return inputName || getTagNameFromSlug(slug) } /** * 게시물 태그 연결 저장 * @param {Function} sql - PostgreSQL 트랜잭션 클라이언트 * @param {string} postId - 게시물 ID * @param {Array} tags - 태그 슬러그 목록 * @param {Object} tagNames - 태그 표시 이름 맵 * @returns {Promise} 저장 결과 */ const syncPostTags = async (sql, postId, tags, tagNames = {}) => { const tagSlugs = normalizeTagSlugs(tags) await sql` DELETE FROM post_tags WHERE post_id = ${postId} ` for (const [index, slug] of tagSlugs.entries()) { const tagRows = await sql` INSERT INTO tags (name, slug, tag_type, sort_order) VALUES (${resolveTagName(slug, tagNames)}, ${slug}, 'general', 0) ON CONFLICT (slug) DO UPDATE SET updated_at = now() RETURNING id ` await sql` INSERT INTO post_tags (post_id, tag_id, sort_order) VALUES (${postId}, ${tagRows[0].id}, ${index * 10}) ON CONFLICT (post_id, tag_id) DO UPDATE SET sort_order = EXCLUDED.sort_order ` } } /** * 공개 게시물 목록 조회 * @param {{ includeMembership?: boolean, includeContent?: boolean, limit?: number|null, offset?: number, tagSlug?: string, featuredOnly?: boolean, includeTotal?: boolean }} [options] - 조회 옵션 * @returns {Promise} 게시물 목록 */ export const listPosts = async ({ includeMembership = false, includeContent = false, limit = null, offset = 0, tagSlug = '', featuredOnly = false, includeTotal = false } = {}) => { const sql = getPostgresClient() const resolvedLimit = Number.isInteger(limit) && limit > 0 ? limit : null const resolvedOffset = Number.isInteger(offset) && offset > 0 ? offset : 0 const normalizedTagSlug = String(tagSlug || '').trim() if (!sql) { let samplePosts = getSamplePosts() if (featuredOnly) { samplePosts = samplePosts.filter((post) => Boolean(post.isFeatured)) } if (normalizedTagSlug) { samplePosts = samplePosts.filter((post) => post.tags.includes(normalizedTagSlug)) } const total = samplePosts.length const selectedItems = resolvedLimit ? samplePosts.slice(resolvedOffset, resolvedOffset + resolvedLimit) : samplePosts const items = includeContent ? selectedItems : selectedItems.map(mapPublicPostSummary) return includeTotal ? { items, total } : items } const rows = await sql` SELECT posts.id, posts.title, posts.slug, posts.author_id, CASE WHEN ${includeContent} = true THEN posts.content ELSE LEFT(posts.content, 4000) END AS content, posts.excerpt, posts.featured_image, posts.show_featured_image, posts.is_featured, posts.admin_pinned, posts.seo_title, posts.seo_description, posts.canonical_url, posts.noindex, posts.og_image, posts.status, posts.published_at, posts.created_at, posts.updated_at, ( SELECT COUNT(*)::int FROM comments WHERE comments.post_id = posts.id AND comments.status = 'published' ) AS comment_count, ( SELECT COALESCE(SUM(post_analytics_daily.views), 0)::int FROM post_analytics_daily WHERE post_analytics_daily.post_id = posts.id ) AS total_views, COALESCE(array_agg(tags.slug ORDER BY post_tags.sort_order ASC, post_tags.created_at ASC, tags.name ASC) 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() ) AND (${featuredOnly} = false OR posts.is_featured = true) AND ( ${normalizedTagSlug || null}::text IS NULL OR EXISTS ( SELECT 1 FROM post_tags tagged_post_tags INNER JOIN tags tagged_tags ON tagged_tags.id = tagged_post_tags.tag_id WHERE tagged_post_tags.post_id = posts.id AND tagged_tags.slug = ${normalizedTagSlug || null} ) ) GROUP BY posts.id ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC LIMIT ${resolvedLimit || 2147483647} OFFSET ${resolvedOffset} ` const mappedRows = rows.map(mapPostRow) const items = includeContent ? mappedRows : mappedRows.map(mapPublicPostSummary) if (!includeTotal) { return items } const totalRows = await sql` SELECT COUNT(*)::int AS total FROM posts 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() ) AND (${featuredOnly} = false OR posts.is_featured = true) AND ( ${normalizedTagSlug || null}::text IS NULL OR EXISTS ( SELECT 1 FROM post_tags tagged_post_tags INNER JOIN tags tagged_tags ON tagged_tags.id = tagged_post_tags.tag_id WHERE tagged_post_tags.post_id = posts.id AND tagged_tags.slug = ${normalizedTagSlug || null} ) ) ` return { items, total: Number(totalRows[0]?.total || 0) } } /** * 관리자 게시물 목록 요약 조회 * @param {Object} [options] - 조회 옵션 * @param {boolean} [options.paged] - 페이지 응답 사용 여부 * @param {number} [options.page] - 페이지 번호 * @param {number} [options.pageSize] - 페이지당 개수 * @param {string} [options.searchQuery] - 검색어 * @param {string} [options.status] - 상태 필터 * @param {string} [options.tagSlug] - 태그 슬러그 * @param {string} [options.featured] - 추천 필터 * @param {string} [options.sortOrder] - 정렬 * @returns {Promise} 관리자 게시물 목록 요약 */ export const listAdminPosts = async (options = {}) => { const sql = getPostgresClient() const paged = Boolean(options.paged) const page = normalizeAdminPostPage(options.page) const pageSize = normalizeAdminPostPageSize(options.pageSize) const offset = (page - 1) * pageSize const normalizedSearchQuery = normalizeAdminPostSearchQuery(options.searchQuery) const searchPattern = normalizedSearchQuery ? `%${normalizedSearchQuery}%` : null const normalizedStatus = normalizeAdminPostStatusFilter(options.status) const normalizedTagSlug = String(options.tagSlug || '').trim() const normalizedFeatured = normalizeAdminPostFeaturedFilter(options.featured) const normalizedSortOrder = normalizeAdminPostSortOrder(options.sortOrder) if (!sql) { let samplePosts = getSamplePosts().map((post) => { const { content, ...summary } = post return summary }) samplePosts = samplePosts.filter((post) => { const sortAt = post.publishedAt || post.updatedAt || post.createdAt const isPublic = post.status === 'published' && (!post.publishedAt || new Date(post.publishedAt) <= new Date()) const statusKey = post.status === 'members' || post.status === 'private' ? post.status : post.status === 'published' && !isPublic ? 'scheduled' : post.status === 'published' ? 'published' : 'draft' const matchesStatus = normalizedStatus === 'all' || statusKey === normalizedStatus const matchesTag = !normalizedTagSlug || (post.tags || []).includes(normalizedTagSlug) const matchesFeatured = normalizedFeatured !== 'featured' || post.isFeatured const searchText = [ post.title, post.slug, post.excerpt, (post.tags || []).join(' ') ].join(' ').toLowerCase() const matchesSearch = !normalizedSearchQuery || searchText.includes(normalizedSearchQuery.toLowerCase()) post.sortAt = sortAt return matchesStatus && matchesTag && matchesFeatured && matchesSearch }) samplePosts.sort((left, right) => { if (Boolean(left.adminPinned) !== Boolean(right.adminPinned)) { return left.adminPinned ? -1 : 1 } const leftTime = new Date(left.sortAt || 0).getTime() const rightTime = new Date(right.sortAt || 0).getTime() return normalizedSortOrder === 'oldest' ? leftTime - rightTime : rightTime - leftTime }) if (!paged) { const items = samplePosts.map(({ sortAt, ...post }) => post) const backlinkCounts = createAdminPostBacklinkCountMap(items, getSamplePosts()) return items.map((post) => ({ ...post, backlinkCount: backlinkCounts.get(post.id) || 0 })) } const total = samplePosts.length const items = samplePosts.slice(offset, offset + pageSize).map(({ sortAt, ...post }) => post) const backlinkCounts = createAdminPostBacklinkCountMap(items, getSamplePosts()) return { items: items.map((post) => ({ ...post, backlinkCount: backlinkCounts.get(post.id) || 0 })), pagination: { page, pageSize, total, pageCount: Math.max(1, Math.ceil(total / pageSize)) }, counts: { total: getSamplePosts().length, featured: getSamplePosts().filter((post) => post.isFeatured).length, pinned: getSamplePosts().filter((post) => post.adminPinned).length } } } const rows = await sql` SELECT posts.id, posts.title, posts.slug, posts.author_id, posts.excerpt, posts.featured_image, posts.show_featured_image, posts.is_featured, posts.admin_pinned, posts.status, posts.published_at, posts.created_at, posts.updated_at, ( SELECT COUNT(*)::int FROM comments WHERE comments.post_id = posts.id AND comments.status = 'published' ) AS comment_count, ( SELECT COALESCE(SUM(post_analytics_daily.views), 0)::int FROM post_analytics_daily WHERE post_analytics_daily.post_id = posts.id ) AS total_views, COALESCE(array_agg(tags.slug ORDER BY post_tags.sort_order ASC, post_tags.created_at ASC, tags.name ASC) 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 ( ${normalizedStatus} = 'all' OR ( ${normalizedStatus} = 'published' AND posts.status = 'published' AND (posts.published_at IS NULL OR posts.published_at <= now()) ) OR ( ${normalizedStatus} = 'scheduled' AND posts.status = 'published' AND posts.published_at > now() ) OR (${normalizedStatus} = 'draft' AND posts.status = 'draft') OR (${normalizedStatus} = 'members' AND posts.status = 'members') OR (${normalizedStatus} = 'private' AND posts.status = 'private') ) AND (${normalizedFeatured} <> 'featured' OR posts.is_featured = true) AND ( ${normalizedTagSlug || null}::text IS NULL OR EXISTS ( SELECT 1 FROM post_tags filtered_post_tags INNER JOIN tags filtered_tags ON filtered_tags.id = filtered_post_tags.tag_id WHERE filtered_post_tags.post_id = posts.id AND filtered_tags.slug = ${normalizedTagSlug || null} ) ) AND ( ${searchPattern}::text IS NULL OR posts.title ILIKE ${searchPattern} OR posts.slug ILIKE ${searchPattern} OR posts.excerpt ILIKE ${searchPattern} OR EXISTS ( SELECT 1 FROM post_tags search_post_tags INNER JOIN tags search_tags ON search_tags.id = search_post_tags.tag_id WHERE search_post_tags.post_id = posts.id AND (search_tags.name ILIKE ${searchPattern} OR search_tags.slug ILIKE ${searchPattern}) ) ) GROUP BY posts.id, posts.title, posts.slug, posts.author_id, posts.excerpt, posts.featured_image, posts.show_featured_image, posts.is_featured, posts.admin_pinned, posts.status, posts.published_at, posts.created_at, posts.updated_at ORDER BY posts.admin_pinned DESC, CASE WHEN ${normalizedSortOrder} = 'oldest' THEN COALESCE(posts.published_at, posts.updated_at) END ASC, CASE WHEN ${normalizedSortOrder} <> 'oldest' THEN COALESCE(posts.published_at, posts.updated_at) END DESC, posts.id DESC LIMIT ${paged ? pageSize : 100000} OFFSET ${paged ? offset : 0} ` const backlinkCounts = await getAdminPostBacklinkCountMap(rows) const mapAdminPostSummaryRowWithBacklinks = (row) => ({ ...mapAdminPostSummaryRow(row), backlinkCount: backlinkCounts.get(row.id) || 0 }) if (!paged) { return rows.map(mapAdminPostSummaryRowWithBacklinks) } const countsRows = await sql` SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE posts.is_featured = true)::int AS featured, COUNT(*) FILTER (WHERE posts.admin_pinned = true)::int AS pinned FROM posts ` const totalRows = await sql` SELECT COUNT(*)::int AS total FROM posts WHERE ( ${normalizedStatus} = 'all' OR ( ${normalizedStatus} = 'published' AND posts.status = 'published' AND (posts.published_at IS NULL OR posts.published_at <= now()) ) OR ( ${normalizedStatus} = 'scheduled' AND posts.status = 'published' AND posts.published_at > now() ) OR (${normalizedStatus} = 'draft' AND posts.status = 'draft') OR (${normalizedStatus} = 'members' AND posts.status = 'members') OR (${normalizedStatus} = 'private' AND posts.status = 'private') ) AND (${normalizedFeatured} <> 'featured' OR posts.is_featured = true) AND ( ${normalizedTagSlug || null}::text IS NULL OR EXISTS ( SELECT 1 FROM post_tags filtered_post_tags INNER JOIN tags filtered_tags ON filtered_tags.id = filtered_post_tags.tag_id WHERE filtered_post_tags.post_id = posts.id AND filtered_tags.slug = ${normalizedTagSlug || null} ) ) AND ( ${searchPattern}::text IS NULL OR posts.title ILIKE ${searchPattern} OR posts.slug ILIKE ${searchPattern} OR posts.excerpt ILIKE ${searchPattern} OR EXISTS ( SELECT 1 FROM post_tags search_post_tags INNER JOIN tags search_tags ON search_tags.id = search_post_tags.tag_id WHERE search_post_tags.post_id = posts.id AND (search_tags.name ILIKE ${searchPattern} OR search_tags.slug ILIKE ${searchPattern}) ) ) ` const total = Number(totalRows[0]?.total || 0) return { items: rows.map(mapAdminPostSummaryRowWithBacklinks), pagination: { page, pageSize, total, pageCount: Math.max(1, Math.ceil(total / pageSize)) }, counts: { total: Number(countsRows[0]?.total || 0), featured: Number(countsRows[0]?.featured || 0), pinned: Number(countsRows[0]?.pinned || 0) } } } /** * 미디어 사용처 계산용 게시물 참조 목록 조회 * @returns {Promise>} 게시물 미디어 참조 목록 */ export const listMediaReferencePosts = async () => { const sql = getPostgresClient() if (!sql) { return getSamplePosts().map((post) => ({ id: post.id, title: post.title, slug: post.slug, status: post.status, featuredImage: post.featuredImage, content: post.content || '' })) } const rows = await sql` SELECT id, title, slug, status, featured_image, content FROM posts ORDER BY updated_at DESC ` return rows.map((row) => ({ id: row.id, title: toAdminPostFormTitle(row.title), slug: row.slug, status: row.status, featuredImage: row.featured_image, content: row.content || '' })) } /** * 게시물을 링크하고 있는 관리자 콘텐츠 목록을 조회한다. * @param {string} id - 참조 대상 게시물 ID * @returns {Promise<{ target: { id: string, title: string, slug: string }, posts: Array<{ id: string, title: string, slug: string, status: string, publishedAt: string | null, updatedAt: string }>, pages: Array<{ id: string, title: string, slug: string, status: string, updatedAt: string }> } | null>} 참조 콘텐츠 목록 */ export const listAdminPostReferences = async (id) => { const sql = getPostgresClient() const target = await getAdminPostById(id) if (!target) { return null } const mapReferenceRow = (post) => ({ id: post.id, title: toAdminPostFormTitle(post.title), slug: post.slug, status: post.status, publishedAt: post.published_at ? post.published_at.toISOString() : post.publishedAt || null, updatedAt: post.updated_at ? post.updated_at.toISOString() : post.updatedAt || '', referenceCount: countPostLinkReferences(post.content, target.slug) }) const removeReferenceCount = ({ referenceCount, ...post }) => post const mapReferencePageRow = (page) => ({ id: page.id, title: toAdminPostFormTitle(page.title), slug: page.slug, status: page.status, updatedAt: page.updated_at ? page.updated_at.toISOString() : page.updatedAt || '', referenceCount: countPostLinkReferences(page.content, target.slug) }) if (!sql) { const posts = getSamplePosts() .filter((post) => post.id !== target.id) .map(mapReferenceRow) .filter((post) => post.referenceCount > 0) .map(removeReferenceCount) const pages = getSamplePages() .map(mapReferencePageRow) .filter((page) => page.referenceCount > 0) .map(removeReferenceCount) return { target: { id: target.id, title: target.title, slug: target.slug }, posts, pages } } const rows = await sql` SELECT id, title, slug, status, published_at, updated_at, content FROM posts WHERE id <> ${target.id} AND content ILIKE ${`%/post/${target.slug}%`} ORDER BY updated_at DESC ` const pageRows = await sql` SELECT id, title, slug, status, updated_at, content FROM pages WHERE content ILIKE ${`%/post/${target.slug}%`} ORDER BY updated_at DESC ` return { target: { id: target.id, title: target.title, slug: target.slug }, posts: rows .map(mapReferenceRow) .filter((post) => post.referenceCount > 0) .map(removeReferenceCount), pages: pageRows .map(mapReferencePageRow) .filter((page) => page.referenceCount > 0) .map(removeReferenceCount) } } /** * 관리자 게시물 상세 조회 * @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 ORDER BY post_tags.sort_order ASC, post_tags.created_at ASC, tags.name ASC) 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, show_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.showFeaturedImage}, ${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, input.tagNames) return insertedRows }) await Promise.all([ createPostThumbnailForImageUrl(input.featuredImage), createPostBodyThumbnailsForContent(input.content), syncContentMediaReferences('post', rows[0].id, input) ]) 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}, show_featured_image = ${input.showFeaturedImage}, 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, input.tagNames) return updatedRows }) if (rows[0]) { await Promise.all([ createPostThumbnailForImageUrl(input.featuredImage), createPostBodyThumbnailsForContent(input.content), syncContentMediaReferences('post', rows[0].id, input) ]) } return rows[0] ? getAdminPostById(rows[0].id) : null } /** * 관리자 게시물 추천 상태 수정 * @param {string} id - 게시물 ID * @param {boolean} isFeatured - 추천 여부 * @returns {Promise} 수정된 게시물 */ export const updateAdminPostFeatured = async (id, isFeatured) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const rows = await sql` UPDATE posts SET is_featured = ${Boolean(isFeatured)}, updated_at = now() WHERE id = ${id} RETURNING id ` return rows[0] ? getAdminPostById(rows[0].id) : null } /** * 관리자 게시물 고정 상태 수정 * @param {string} id - 게시물 ID * @param {boolean} adminPinned - 관리자 목록 상단 고정 여부 * @returns {Promise} 수정된 게시물 */ export const updateAdminPostPinned = async (id, adminPinned) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const rows = await sql` UPDATE posts SET admin_pinned = ${Boolean(adminPinned)}, updated_at = now() WHERE id = ${id} RETURNING id ` 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 ORDER BY post_tags.sort_order ASC, post_tags.created_at ASC, tags.name ASC) 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 } /** * 공개 게시물 상세의 이전(더 예전)·다음(더 최신) 글을 조회한다. * 목록과 같은 정렬(발행일 DESC) 기준으로 인접 글의 제목·슬러그만 반환한다. * @param {string} slug - 기준 게시물 슬러그 * @param {{ includeMembership?: boolean }} [options] - 조회 옵션 * @returns {Promise<{ previous: { title: string, slug: string } | null, next: { title: string, slug: string } | null }>} 인접 글 */ export const getAdjacentPosts = async (slug, { includeMembership = false } = {}) => { const sql = getPostgresClient() if (!sql) { const samplePosts = getSamplePosts() .filter((post) => post.status === 'published' && (!post.publishedAt || new Date(post.publishedAt) <= new Date())) .sort((left, right) => { const leftTime = new Date(left.publishedAt || left.createdAt || 0).getTime() const rightTime = new Date(right.publishedAt || right.createdAt || 0).getTime() return rightTime - leftTime }) const index = samplePosts.findIndex((post) => post.slug === slug) if (index === -1) { return { previous: null, next: null } } const toNeighbor = (post) => (post ? { title: post.title, slug: post.slug } : null) return { previous: index < samplePosts.length - 1 ? toNeighbor(samplePosts[index + 1]) : null, next: index > 0 ? toNeighbor(samplePosts[index - 1]) : null } } const rows = await sql` WITH ordered AS ( SELECT posts.slug, posts.title, LAG(posts.slug) OVER w AS previous_slug, LAG(posts.title) OVER w AS previous_title, LEAD(posts.slug) OVER w AS next_slug, LEAD(posts.title) OVER w AS next_title FROM posts 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() ) WINDOW w AS (ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC) ) SELECT previous_slug, previous_title, next_slug, next_title FROM ordered WHERE slug = ${slug} LIMIT 1 ` const row = rows[0] if (!row) { return { previous: null, next: null } } return { previous: row.next_slug ? { title: row.next_title, slug: row.next_slug } : null, next: row.previous_slug ? { title: row.previous_title, slug: row.previous_slug } : null } } /** * 공개 고정 페이지 목록 조회 * @param {{ listedOnly?: boolean, limitPerSection?: number|null }} [options] - 조회 옵션 * @returns {Promise} 고정 페이지 목록 */ export const listPages = async (options = {}) => { const sql = getPostgresClient() const listedOnly = Boolean(options.listedOnly) const limitPerSection = Number.isInteger(options.limitPerSection) && options.limitPerSection > 0 ? options.limitPerSection : null if (!sql) { const pages = getSamplePages().filter((page) => page.status === 'published') if (!listedOnly) { return pages } const sectionCounts = new Map() return pages .filter((page) => page.isListed) .filter((page) => { if (!limitPerSection) { return true } const sectionName = String(page.listingSection || '').trim() || 'Pages' const count = Number(sectionCounts.get(sectionName) || 0) + 1 sectionCounts.set(sectionName, count) return count <= limitPerSection }) .map((page) => ({ ...page, content: '' })) } if (listedOnly) { const rows = await sql` SELECT * FROM ( SELECT pages.id, pages.title, pages.slug, '' AS content, pages.render_mode, pages.status, pages.featured_image, pages.is_listed, pages.is_featured, pages.listing_section, pages.listing_title, pages.listing_description, pages.listing_sort_order, pages.created_at, pages.updated_at, COALESCE(page_listing_sections.sort_order, 9999) AS section_sort_order, row_number() OVER ( PARTITION BY COALESCE(NULLIF(pages.listing_section, ''), 'Pages') ORDER BY pages.is_featured DESC, pages.listing_sort_order ASC, pages.updated_at DESC, pages.created_at DESC ) AS section_row_number FROM pages LEFT JOIN page_listing_sections ON page_listing_sections.name = pages.listing_section WHERE pages.status = 'published' AND pages.is_listed = true ) listed_pages WHERE (${limitPerSection || null}::int IS NULL OR listed_pages.section_row_number <= ${limitPerSection || null}) ORDER BY listed_pages.section_sort_order ASC, listed_pages.listing_section ASC, listed_pages.is_featured DESC, listed_pages.listing_sort_order ASC, listed_pages.updated_at DESC, listed_pages.created_at DESC ` return rows.map(mapPageRow) } const rows = await sql` SELECT pages.* FROM pages LEFT JOIN page_listing_sections ON page_listing_sections.name = pages.listing_section WHERE status = 'published' AND (${listedOnly} = false OR is_listed = true) ORDER BY COALESCE(page_listing_sections.sort_order, 9999) ASC, listing_section ASC, is_featured DESC, listing_sort_order ASC, updated_at DESC, created_at DESC ` return rows.map(mapPageRow) } /** * 미디어 사용처 계산용 페이지 참조 목록 조회 * @returns {Promise>} 페이지 미디어 참조 목록 */ export const listMediaReferencePages = async () => { const sql = getPostgresClient() if (!sql) { return getSamplePages().map((page) => ({ id: page.id, title: page.title, slug: page.slug, status: page.status, featuredImage: page.featuredImage, content: page.content || '' })) } const rows = await sql` SELECT id, title, slug, status, featured_image, content FROM pages ORDER BY updated_at DESC ` return rows.map((row) => ({ id: row.id, title: row.title, slug: row.slug, status: row.status, featuredImage: row.featured_image, content: row.content || '' })) } /** * 관리자 고정 페이지 목록 조회 * @returns {Promise} 관리자 고정 페이지 목록 */ export const listAdminPages = async () => { const sql = getPostgresClient() if (!sql) { return getSamplePages() } const rows = await sql` SELECT pages.*, ( SELECT COALESCE(SUM(page_analytics_daily.views), 0)::int FROM page_analytics_daily WHERE page_analytics_daily.page_id = pages.id ) AS total_views 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 } /** * 관리자 페이지 섹션 목록 조회 * @returns {Promise} 페이지 섹션 목록 */ export const listAdminPageSections = async () => { const sql = getPostgresClient() if (!sql) { const sections = new Map() for (const page of getSamplePages()) { const name = String(page.listingSection || '').trim() if (!name) { continue } sections.set(name, { id: `sample-page-section-${sections.size + 1}`, name, sortOrder: sections.size * 10, pageCount: (sections.get(name)?.pageCount || 0) + 1, createdAt: page.createdAt, updatedAt: page.updatedAt }) } return [...sections.values()] } const rows = await sql` SELECT page_listing_sections.*, ( SELECT COUNT(*)::int FROM pages WHERE pages.listing_section = page_listing_sections.name ) AS page_count FROM page_listing_sections ORDER BY sort_order ASC, name ASC ` return rows.map(mapPageSectionRow) } /** * 관리자 페이지 섹션 생성 * @param {string} name - 섹션명 * @returns {Promise} 생성된 페이지 섹션 */ export const createAdminPageSection = async (name) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const rows = await sql` INSERT INTO page_listing_sections (name, sort_order) VALUES ( ${name}, COALESCE((SELECT MAX(sort_order) + 10 FROM page_listing_sections), 0) ) RETURNING *, 0::int AS page_count ` return mapPageSectionRow(rows[0]) } /** * 관리자 페이지 섹션 수정 * @param {string} id - 섹션 ID * @param {{ name: string }} input - 수정 입력값 * @returns {Promise} 수정된 페이지 섹션 */ export const updateAdminPageSection = async (id, input) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const currentRows = await sql` SELECT * FROM page_listing_sections WHERE id = ${id} LIMIT 1 ` if (!currentRows[0]) { return null } await sql.begin(async (transaction) => { await transaction` UPDATE page_listing_sections SET name = ${input.name}, updated_at = now() WHERE id = ${id} ` await transaction` UPDATE pages SET listing_section = ${input.name}, updated_at = now() WHERE listing_section = ${currentRows[0].name} ` }) const rows = await sql` SELECT page_listing_sections.*, ( SELECT COUNT(*)::int FROM pages WHERE pages.listing_section = page_listing_sections.name ) AS page_count FROM page_listing_sections WHERE id = ${id} LIMIT 1 ` return rows[0] ? mapPageSectionRow(rows[0]) : null } /** * 관리자 페이지 섹션 삭제 * @param {string} id - 섹션 ID * @returns {Promise} 삭제 여부 */ export const deleteAdminPageSection = async (id) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const rows = await sql` DELETE FROM page_listing_sections WHERE id = ${id} AND NOT EXISTS ( SELECT 1 FROM pages WHERE pages.listing_section = page_listing_sections.name ) RETURNING id ` return Boolean(rows[0]) } /** * 관리자 페이지 섹션 정렬 저장 * @param {Array} sectionIds - 섹션 ID 목록 * @returns {Promise} 정렬 저장 후 섹션 목록 */ export const reorderAdminPageSections = async (sectionIds) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } await sql.begin(async (transaction) => { for (const [index, sectionId] of sectionIds.entries()) { await transaction` UPDATE page_listing_sections SET sort_order = ${index * 10}, updated_at = now() WHERE id = ${sectionId} ` } }) return listAdminPageSections() } /** * 관리자 페이지 섹션 내부 페이지 정렬 저장 * @param {string} sectionId - 페이지 섹션 ID * @param {Array} pageIds - 정렬할 페이지 ID 목록 * @param {Array} removedPageIds - 섹션에서 제거할 페이지 ID 목록 * @returns {Promise} 정렬 저장 후 페이지 목록 */ export const reorderAdminPageSectionPages = async (sectionId, pageIds, removedPageIds = []) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const sectionRows = await sql` SELECT * FROM page_listing_sections WHERE id = ${sectionId} LIMIT 1 ` if (!sectionRows[0]) { return null } const removedIdSet = new Set(removedPageIds.map((id) => String(id))) const nextPageIds = [...new Set(pageIds.map((id) => String(id)).filter((id) => !removedIdSet.has(id)))] const nextRemovedPageIds = [...removedIdSet] const targetPageIds = [...new Set([...nextPageIds, ...nextRemovedPageIds])] if (targetPageIds.length === 0) { return listAdminPages() } const pageRows = await sql` SELECT id FROM pages WHERE listing_section = ${sectionRows[0].name} AND is_listed = true AND id = ANY(${targetPageIds}) ` if (pageRows.length !== targetPageIds.length) { throw new Error('PAGE_SECTION_PAGE_MISMATCH') } await sql.begin(async (transaction) => { if (nextRemovedPageIds.length > 0) { await transaction` UPDATE pages SET listing_section = '', listing_sort_order = 0, updated_at = now() WHERE listing_section = ${sectionRows[0].name} AND is_listed = true AND id = ANY(${nextRemovedPageIds}) ` } for (const [index, pageId] of nextPageIds.entries()) { await transaction` UPDATE pages SET listing_sort_order = ${index * 10}, updated_at = now() WHERE id = ${pageId} AND listing_section = ${sectionRows[0].name} AND is_listed = true ` } }) return listAdminPages() } /** * 관리자 고정 페이지 생성 * @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, is_listed, is_featured, listing_section, listing_title, listing_description, listing_sort_order, status ) VALUES ( ${input.title}, ${input.slug}, ${input.content}, ${input.renderMode}, ${input.featuredImage}, ${input.isListed}, ${input.isFeatured}, ${input.listingSection}, ${input.listingTitle}, ${input.listingDescription}, ${input.listingSortOrder}, ${input.status} ) RETURNING * ` await Promise.all([ createPageCoverThumbnailForImageUrl(input.featuredImage), syncContentMediaReferences('page', rows[0].id, input) ]) 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}, is_listed = ${input.isListed}, is_featured = ${input.isFeatured}, listing_section = ${input.listingSection}, listing_title = ${input.listingTitle}, listing_description = ${input.listingDescription}, listing_sort_order = ${input.listingSortOrder}, status = ${input.status}, updated_at = now() WHERE id = ${id} RETURNING * ` if (rows[0]) { await Promise.all([ createPageCoverThumbnailForImageUrl(input.featuredImage), syncContentMediaReferences('page', rows[0].id, input) ]) } 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, headerImageUrl: tag.headerImageUrl || '', headerTitle: tag.headerTitle || '', headerText: tag.headerText || '', 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(posts.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 AND posts.status = 'published' AND ( posts.published_at IS NULL OR posts.published_at <= now() ) 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_PAGE_LIMIT = 8 /** * 공개 검색어를 비교 가능한 문자열로 정리한다. * @param {string} value - 원본 검색어 * @returns {string} 정규화된 검색어 */ const normalizeSearchQuery = (value) => String(value || '') .normalize('NFC') .replace(/\s+/g, ' ') .trim() /** * 공개 검색에서 업로드 경로/파일명 같은 노이즈를 제거한다. * - 마크다운 이미지 문법 `![](...)` 제거 * - 업로드 경로(`/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 }>, pages: Array<{ slug: string, title: string, excerpt: string }> }>} 검색 요약 결과 */ export const searchPublicContent = async (rawQuery) => { const q = normalizeSearchQuery(rawQuery) if (!q) { return { tags: [], posts: [], pages: [] } } const searchTextQuery = normalizeSearchContent(q).toLowerCase() if (!searchTextQuery) { return { tags: [], posts: [], pages: [] } } const sql = getPostgresClient() if (!sql) { const needle = searchTextQuery 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 })) const pages = getSamplePages() .filter((page) => { const hay = normalizeSearchContent(`${page.listingSection || ''}\n${page.listingTitle || page.title}\n${page.listingDescription || ''}\n${page.content || ''}`).toLowerCase() return page.isListed && hay.includes(needle) }) .slice(0, SEARCH_PAGE_LIMIT) .map((page) => ({ slug: page.slug, title: page.listingTitle || page.title, excerpt: page.listingDescription || '' })) return { tags, posts, pages } } const searchPattern = `%${searchTextQuery}%` const searchPrefixPattern = `${searchTextQuery}%` const tagRows = await sql` SELECT name, slug FROM tags WHERE lower(name) LIKE ${searchPattern} OR lower(slug) LIKE ${searchPattern} ORDER BY CASE WHEN lower(name) = ${searchTextQuery} OR lower(slug) = ${searchTextQuery} THEN 0 WHEN lower(name) LIKE ${searchPrefixPattern} OR lower(slug) LIKE ${searchPrefixPattern} THEN 1 ELSE 2 END, sort_order ASC, name ASC LIMIT ${SEARCH_TAG_LIMIT} ` const postRows = await sql` SELECT posts.slug, posts.title, posts.excerpt FROM posts WHERE posts.status = 'published' AND (posts.published_at IS NULL OR posts.published_at <= now()) AND posts.search_text LIKE ${searchPattern} ORDER BY CASE WHEN lower(posts.title) = ${searchTextQuery} THEN 0 WHEN lower(posts.title) LIKE ${searchPrefixPattern} THEN 1 WHEN lower(coalesce(posts.excerpt, '')) LIKE ${searchPrefixPattern} THEN 2 ELSE 3 END, posts.published_at DESC NULLS LAST, posts.created_at DESC LIMIT ${SEARCH_POST_LIMIT} ` const pageRows = await sql` SELECT slug, title, listing_section, listing_title, listing_description FROM pages WHERE status = 'published' AND is_listed = true AND search_text LIKE ${searchPattern} ORDER BY CASE WHEN lower(coalesce(listing_title, title)) = ${searchTextQuery} THEN 0 WHEN lower(coalesce(listing_title, title)) LIKE ${searchPrefixPattern} THEN 1 WHEN lower(coalesce(listing_description, '')) LIKE ${searchPrefixPattern} THEN 2 ELSE 3 END, is_featured DESC, listing_section ASC, listing_sort_order ASC, updated_at DESC LIMIT ${SEARCH_PAGE_LIMIT} ` return { tags: tagRows.map((row) => ({ name: row.name, slug: row.slug })), posts: postRows.map((row) => ({ slug: row.slug, title: row.title, excerpt: row.excerpt || '' })), pages: pageRows.map((row) => ({ slug: row.slug, title: row.listing_title || row.title, excerpt: row.listing_description || '' })) } } /** * 사이트 설정 조회 * @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, social_links, show_post_updated_at, post_tag_limit, home_latest_post_limit, post_list_page_size, tag_post_list_page_size, featured_post_limit, page_section_item_limit, upload_max_file_count, upload_max_total_size, home_cover_image_url, home_cover_dark_image_url, home_cover_title, home_cover_text, brand_color, announcement_enabled, announcement_text, announcement_url, announcement_background_color, announcement_alignment, signup_blocked_usernames, ads_txt, custom_head_code, custom_footer_code, ad_home_feed_code, ad_home_infeed_code, ad_sidebar_code, ad_post_sidebar_code, ad_post_top_code, ad_post_in_article_code, ad_post_bottom_code, updated_at ) VALUES ( 1, ${input.title}, ${input.description}, ${input.siteUrl}, ${input.logoText || '井'}, ${input.logoUrl || ''}, ${input.faviconUrl || ''}, ${input.copyrightText}, ${JSON.stringify(normalizeSocialLinks(input.socialLinks))}::jsonb, ${input.showPostUpdatedAt ? true : false}, ${normalizePostTagLimit(input.postTagLimit)}, ${normalizeDisplayCount(input.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT)}, ${normalizeDisplayCount(input.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE)}, ${normalizeDisplayCount(input.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)}, ${normalizeDisplayCount(input.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)}, ${normalizeDisplayCount(input.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)}, ${normalizeUploadMaxFileCount(input.uploadMaxFileCount)}, ${normalizeUploadMaxTotalSize(input.uploadMaxTotalSize)}, ${input.homeCoverImageUrl || ''}, ${input.homeCoverDarkImageUrl || ''}, ${input.homeCoverTitle || ''}, ${input.homeCoverText || ''}, ${normalizeBrandColor(input.brandColor)}, ${input.announcementEnabled ? true : false}, ${input.announcementText || ''}, ${input.announcementUrl || ''}, ${normalizeAnnouncementBackgroundColor(input.announcementBackgroundColor)}, ${normalizeAnnouncementAlignment(input.announcementAlignment)}, ${JSON.stringify(normalizeSignupBlockedUsernames(input.signupBlockedUsernames))}, ${input.adsTxt || ''}, ${input.customHeadCode || ''}, ${input.customFooterCode || ''}, ${input.adHomeFeedCode || ''}, ${input.adHomeInfeedCode || ''}, ${input.adSidebarCode || ''}, ${input.adPostSidebarCode || ''}, ${input.adPostTopCode || ''}, ${input.adPostInArticleCode || ''}, ${input.adPostBottomCode || ''}, 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, social_links = EXCLUDED.social_links, show_post_updated_at = EXCLUDED.show_post_updated_at, post_tag_limit = EXCLUDED.post_tag_limit, home_latest_post_limit = EXCLUDED.home_latest_post_limit, post_list_page_size = EXCLUDED.post_list_page_size, tag_post_list_page_size = EXCLUDED.tag_post_list_page_size, featured_post_limit = EXCLUDED.featured_post_limit, page_section_item_limit = EXCLUDED.page_section_item_limit, upload_max_file_count = EXCLUDED.upload_max_file_count, upload_max_total_size = EXCLUDED.upload_max_total_size, 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, brand_color = EXCLUDED.brand_color, announcement_enabled = EXCLUDED.announcement_enabled, announcement_text = EXCLUDED.announcement_text, announcement_url = EXCLUDED.announcement_url, announcement_background_color = EXCLUDED.announcement_background_color, announcement_alignment = EXCLUDED.announcement_alignment, 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, ad_home_feed_code = EXCLUDED.ad_home_feed_code, ad_home_infeed_code = EXCLUDED.ad_home_infeed_code, ad_sidebar_code = EXCLUDED.ad_sidebar_code, ad_post_sidebar_code = EXCLUDED.ad_post_sidebar_code, ad_post_top_code = EXCLUDED.ad_post_top_code, ad_post_in_article_code = EXCLUDED.ad_post_in_article_code, ad_post_bottom_code = EXCLUDED.ad_post_bottom_code, updated_at = now() RETURNING * ` const settings = mapSiteSettingsRow(rows[0]) await syncSiteSettingsMediaReferences(settings) return settings } /** * 전달된 사이트 설정 필드만 원자적으로 수정한다. * @param {Object} input - 부분 사이트 설정 입력값 * @returns {Promise} 수정된 사이트 설정 */ export const updateSiteSettingsPartial = async (input) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const hasOwn = (key) => Object.prototype.hasOwnProperty.call(input, key) const values = {} if (hasOwn('title')) values.title = input.title if (hasOwn('description')) values.description = input.description if (hasOwn('siteUrl')) values.site_url = input.siteUrl if (hasOwn('logoText')) values.logo_text = input.logoText || '井' if (hasOwn('logoUrl')) values.logo_url = input.logoUrl || '' if (hasOwn('faviconUrl')) values.favicon_url = input.faviconUrl || '' if (hasOwn('copyrightText')) values.copyright_text = input.copyrightText if (hasOwn('socialLinks')) values.social_links = sql.json(normalizeSocialLinks(input.socialLinks)) if (hasOwn('showPostUpdatedAt')) values.show_post_updated_at = Boolean(input.showPostUpdatedAt) if (hasOwn('postTagLimit')) values.post_tag_limit = normalizePostTagLimit(input.postTagLimit) if (hasOwn('homeLatestPostLimit')) values.home_latest_post_limit = normalizeDisplayCount(input.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT) if (hasOwn('postListPageSize')) values.post_list_page_size = normalizeDisplayCount(input.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE) if (hasOwn('tagPostListPageSize')) values.tag_post_list_page_size = normalizeDisplayCount(input.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE) if (hasOwn('featuredPostLimit')) values.featured_post_limit = normalizeDisplayCount(input.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT) if (hasOwn('pageSectionItemLimit')) values.page_section_item_limit = normalizeDisplayCount(input.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT) if (hasOwn('uploadMaxFileCount')) values.upload_max_file_count = normalizeUploadMaxFileCount(input.uploadMaxFileCount) if (hasOwn('uploadMaxTotalSize')) values.upload_max_total_size = normalizeUploadMaxTotalSize(input.uploadMaxTotalSize) if (hasOwn('homeCoverImageUrl')) values.home_cover_image_url = input.homeCoverImageUrl || '' if (hasOwn('homeCoverDarkImageUrl')) values.home_cover_dark_image_url = input.homeCoverDarkImageUrl || '' if (hasOwn('homeCoverTitle')) values.home_cover_title = input.homeCoverTitle || '' if (hasOwn('homeCoverText')) values.home_cover_text = input.homeCoverText || '' if (hasOwn('brandColor')) values.brand_color = normalizeBrandColor(input.brandColor) if (hasOwn('announcementEnabled')) values.announcement_enabled = Boolean(input.announcementEnabled) if (hasOwn('announcementText')) values.announcement_text = input.announcementText || '' if (hasOwn('announcementUrl')) values.announcement_url = input.announcementUrl || '' if (hasOwn('announcementBackgroundColor')) values.announcement_background_color = normalizeAnnouncementBackgroundColor(input.announcementBackgroundColor) if (hasOwn('announcementAlignment')) values.announcement_alignment = normalizeAnnouncementAlignment(input.announcementAlignment) if (hasOwn('signupBlockedUsernames')) values.signup_blocked_usernames = JSON.stringify(normalizeSignupBlockedUsernames(input.signupBlockedUsernames)) if (hasOwn('adsTxt')) values.ads_txt = input.adsTxt || '' if (hasOwn('customHeadCode')) values.custom_head_code = input.customHeadCode || '' if (hasOwn('customFooterCode')) values.custom_footer_code = input.customFooterCode || '' if (hasOwn('adHomeFeedCode')) values.ad_home_feed_code = input.adHomeFeedCode || '' if (hasOwn('adHomeInfeedCode')) values.ad_home_infeed_code = input.adHomeInfeedCode || '' if (hasOwn('adSidebarCode')) values.ad_sidebar_code = input.adSidebarCode || '' if (hasOwn('adPostSidebarCode')) values.ad_post_sidebar_code = input.adPostSidebarCode || '' if (hasOwn('adPostTopCode')) values.ad_post_top_code = input.adPostTopCode || '' if (hasOwn('adPostInArticleCode')) values.ad_post_in_article_code = input.adPostInArticleCode || '' if (hasOwn('adPostBottomCode')) values.ad_post_bottom_code = input.adPostBottomCode || '' if (!Object.keys(values).length) { return getSiteSettings() } const rows = await sql` UPDATE site_settings SET ${sql(values)}, updated_at = now() WHERE id = 1 RETURNING * ` if (!rows[0]) { throw new Error('SITE_SETTINGS_NOT_FOUND') } const settings = mapSiteSettingsRow(rows[0]) await syncSiteSettingsMediaReferences(settings) return settings } /** * 사이트 로고 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 * ` const settings = mapSiteSettingsRow(rows[0]) await syncSiteSettingsMediaReferences(settings) return settings } /** * 메인 화면 커버 이미지 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 * ` const settings = mapSiteSettingsRow(rows[0]) await syncSiteSettingsMediaReferences(settings) return settings } /** * 네비게이션 항목 목록 조회 * @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, cachedFaviconUrl: item.cachedFaviconUrl, isVisible: item.isVisible })) } } /** * 관리자 네비게이션 항목 일괄 저장 * @param {Array} items - 저장할 네비게이션 항목 목록 * @returns {Promise} 저장된 네비게이션 항목 목록 */ export const updateNavigationItems = async (items) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const existingRows = await sql` SELECT id, cached_favicon_url, favicon_cached_at FROM navigation_items ` const faviconCacheById = new Map( existingRows.map((row) => [ String(row.id), { cachedFaviconUrl: row.cached_favicon_url || '', faviconCachedAt: row.favicon_cached_at ?? null } ]) ) await sql.begin(async (transaction) => { await transaction` DELETE FROM navigation_items ` const ordered = orderNavigationItemsForInsert(items) for (const item of ordered) { const cached = faviconCacheById.get(String(item.id)) || { cachedFaviconUrl: '', faviconCachedAt: null } await transaction` INSERT INTO navigation_items ( id, label, url, description_text, thumbnail_url, cached_favicon_url, favicon_cached_at, location, sort_order, is_visible, parent_id, is_folder ) VALUES ( ${item.id}, ${item.label}, ${item.url}, ${item.descriptionText || ''}, ${item.thumbnailUrl || ''}, ${cached.cachedFaviconUrl}, ${cached.faviconCachedAt}, ${item.location}, ${item.sortOrder}, ${item.isVisible}, ${item.parentId ?? null}, ${Boolean(item.isFolder)} ) ` } }) return listNavigationItems() } /** * 네비게이션 항목 단건 조회 * @param {string} id - 항목 ID * @returns {Promise} 네비게이션 항목 */ export const getNavigationItemById = async (id) => { const sql = getPostgresClient() if (!sql) { return null } const rows = await sql` SELECT * FROM navigation_items WHERE id = ${id} LIMIT 1 ` return rows[0] ? mapNavigationItemRow(rows[0]) : null } /** * 추천 사이트 파비콘 캐시를 갱신한다. * @param {string} id - 항목 ID * @param {string} cachedFaviconUrl - 저장된 파비콘 URL * @returns {Promise} 갱신된 항목 */ export const updateNavigationItemFaviconCache = async (id, cachedFaviconUrl) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const rows = await sql` UPDATE navigation_items SET cached_favicon_url = ${cachedFaviconUrl}, favicon_cached_at = NOW(), updated_at = NOW() WHERE id = ${id} RETURNING * ` return rows[0] ? mapNavigationItemRow(rows[0]) : null } /** * 관리자 태그 상세 조회 * @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, header_image_url, header_title, header_text, sort_order, color, tag_type) VALUES (${input.name}, ${input.slug}, ${input.description}, ${input.headerImageUrl}, ${input.headerTitle}, ${input.headerText}, ${input.sortOrder}, ${input.color}, ${input.tagType}) RETURNING * ` const tag = mapTagRow(rows[0]) await syncTagMediaReferences(tag) return tag } /** * 관리자 태그 수정 * @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}, header_image_url = ${input.headerImageUrl}, header_title = ${input.headerTitle}, header_text = ${input.headerText}, sort_order = ${input.sortOrder}, color = ${input.color}, tag_type = ${input.tagType}, updated_at = now() WHERE id = ${id} RETURNING * ` if (!rows[0]) { return null } const tag = mapTagRow(rows[0]) await syncTagMediaReferences(tag) return tag } /** * 관리자 관리용 태그 순서를 일괄 갱신 * @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]) }