Files
sori.studio/server/repositories/content-repository.js
zenn de767095ba v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 11:05:40 +09:00

2909 lines
86 KiB
JavaScript

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<void>}
*/
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<void>}
*/
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<void>}
*/
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<string, number>} 게시물 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<Map<string, number>>} 게시물 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<string>} tags - 태그 슬러그 목록
* @returns {Array<string>} 정규화된 태그 슬러그 목록
*/
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<string>} tags - 태그 슬러그 목록
* @param {Object} tagNames - 태그 표시 이름 맵
* @returns {Promise<void>} 저장 결과
*/
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<Array|{ items: Array, total: number }>} 게시물 목록
*/
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<Array|Object>} 관리자 게시물 목록 요약
*/
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<Array<{ id: string, title: string, slug: string, status: string, featuredImage: string | null, content: string }>>} 게시물 미디어 참조 목록
*/
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<Object | null>} 관리자 게시물 상세
*/
export const getAdminPostById = async (id) => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePosts().find((post) => post.id === id) || null
}
const rows = await sql`
SELECT
posts.*,
(
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<Object>} 생성된 게시물
*/
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<Object | null>} 수정된 게시물
*/
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<Object | null>} 수정된 게시물
*/
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<Object | null>} 수정된 게시물
*/
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<boolean>} 삭제 여부
*/
export const deleteAdminPost = async (id) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
DELETE FROM posts
WHERE id = ${id}
RETURNING id
`
return Boolean(rows[0])
}
/**
* 공개 게시물 상세 조회
* @param {string} slug - 게시물 슬러그
* @param {{ includeMembership?: boolean }} [options] - VIP 전용 글 포함 여부
* @returns {Promise<Object | null>} 게시물 상세
*/
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<Array>} 고정 페이지 목록
*/
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<Array<{ id: string, title: string, slug: string, status: string, featuredImage: string | null, content: string }>>} 페이지 미디어 참조 목록
*/
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<Array>} 관리자 고정 페이지 목록
*/
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<Object | null>} 관리자 고정 페이지 상세
*/
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<Array>} 페이지 섹션 목록
*/
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<Object>} 생성된 페이지 섹션
*/
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<Object | null>} 수정된 페이지 섹션
*/
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<boolean>} 삭제 여부
*/
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<string>} sectionIds - 섹션 ID 목록
* @returns {Promise<Array>} 정렬 저장 후 섹션 목록
*/
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<string>} pageIds - 정렬할 페이지 ID 목록
* @param {Array<string>} removedPageIds - 섹션에서 제거할 페이지 ID 목록
* @returns {Promise<Array>} 정렬 저장 후 페이지 목록
*/
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<Object>} 생성된 페이지
*/
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<Object | null>} 수정된 페이지
*/
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<boolean>} 삭제 여부
*/
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<Object | null>} 고정 페이지 상세
*/
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<Array>} 태그 목록
*/
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<Array>} 관리자 태그 목록
*/
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<Object>} 사이트 설정
*/
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<Object>} 수정된 사이트 설정
*/
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<Object>} 수정된 사이트 설정
*/
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<Object>} 수정된 사이트 설정
*/
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<Object>} 수정된 사이트 설정
*/
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<Array>} 네비게이션 항목 목록
*/
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<Object>, footer: Array<Object>, recommended: Array<Object>}>} 위치별 공개 네비게이션
*/
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<Object>} items - 저장할 네비게이션 항목 목록
* @returns {Promise<Array>} 저장된 네비게이션 항목 목록
*/
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<Object | null>} 네비게이션 항목
*/
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<Object | null>} 갱신된 항목
*/
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<Object | null>} 태그 상세
*/
export const getAdminTagById = async (id) => {
const sql = getPostgresClient()
if (!sql) {
return getSampleTags().find((tag) => tag.id === id) || null
}
const rows = await sql`
SELECT *
FROM tags
WHERE id = ${id}
LIMIT 1
`
return rows[0] ? mapTagRow(rows[0]) : null
}
/**
* 관리자 태그 생성
* @param {Object} input - 태그 입력값
* @returns {Promise<Object>} 생성된 태그
*/
export const createAdminTag = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
INSERT INTO tags (name, slug, description, 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<Object | null>} 수정된 태그
*/
export const updateAdminTag = async (id, input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
UPDATE tags
SET
name = ${input.name},
slug = ${input.slug},
description = ${input.description},
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<string>} tagIds - 정렬된 태그 ID 목록
* @returns {Promise<Array>} 갱신된 태그 목록
*/
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<boolean>} 삭제 여부
*/
export const deleteAdminTag = async (id) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
DELETE FROM tags
WHERE id = ${id}
RETURNING id
`
return Boolean(rows[0])
}