Files
sori.studio/server/repositories/content-repository.js
zenn cdc16c72b2 태그를 관리용/일반용으로 분리하고 관리자 드래그 정렬을 추가.
댓글/회원/관리자 인증·프로필 흐름 보완과 관련 마이그레이션 및 문서를 함께 반영해 운영 동선을 안정화.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:34:23 +09:00

937 lines
22 KiB
JavaScript

import {
getSamplePageBySlug,
getSamplePages,
getSamplePostBySlug,
getSamplePosts,
getSampleTags
} from '../utils/sample-content'
import { getDefaultNavigationItems, groupNavigationItems } from '../utils/navigation-items'
import { getDefaultSiteSettings } from '../utils/site-settings'
import { getPostgresClient } from './postgres-client'
/**
* 게시물 행을 API 응답 구조로 변환
* @param {Object} row - 게시물 행
* @returns {Object} 게시물 응답
*/
const mapPostRow = (row) => ({
id: row.id,
title: row.title,
slug: row.slug,
content: row.content,
excerpt: row.excerpt,
featuredImage: row.featured_image,
seoTitle: row.seo_title || '',
seoDescription: row.seo_description || '',
canonicalUrl: row.canonical_url || '',
noindex: Boolean(row.noindex),
ogImage: row.og_image || null,
status: row.status,
publishedAt: row.published_at ? row.published_at.toISOString() : null,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
tags: row.tags || []
})
/**
* 고정 페이지 행을 API 응답 구조로 변환
* @param {Object} row - 고정 페이지 행
* @returns {Object} 고정 페이지 응답
*/
const mapPageRow = (row) => ({
id: row.id,
title: row.title,
slug: row.slug,
content: row.content,
featuredImage: row.featured_image,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString()
})
/**
* 태그 행을 API 응답 구조로 변환
* @param {Object} row - 태그 행
* @returns {Object} 태그 응답
*/
const mapTagRow = (row) => ({
id: row.id,
name: row.name,
slug: row.slug,
description: row.description,
sortOrder: row.sort_order,
color: row.color,
tagType: row.tag_type || 'managed'
})
/**
* 사이트 설정 행을 API 응답 구조로 변환
* @param {Object} row - 사이트 설정 행
* @returns {Object} 사이트 설정 응답
*/
const mapSiteSettingsRow = (row) => ({
title: row.title,
description: row.description,
siteUrl: row.site_url,
logoText: row.logo_text,
copyrightText: row.copyright_text,
updatedAt: row.updated_at.toISOString()
})
/**
* 네비게이션 행을 API 응답 구조로 변환
* @param {Object} row - 네비게이션 행
* @returns {Object} 네비게이션 응답
*/
const mapNavigationItemRow = (row) => ({
id: row.id,
label: row.label,
url: row.url,
location: row.location,
sortOrder: row.sort_order,
isVisible: row.is_visible,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString()
})
/**
* 태그 슬러그 목록 정규화
* @param {Array<string>} tags - 태그 슬러그 목록
* @returns {Array<string>} 정규화된 태그 슬러그 목록
*/
const normalizeTagSlugs = (tags = []) => [...new Set(tags
.map((tag) => String(tag).trim().toLowerCase())
.filter(Boolean))]
/**
* 태그 슬러그를 태그명으로 변환
* @param {string} slug - 태그 슬러그
* @returns {string} 태그명
*/
const getTagNameFromSlug = (slug) => slug
.split('-')
.filter(Boolean)
.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
.join(' ')
/**
* 게시물 태그 연결 저장
* @param {Function} sql - PostgreSQL 트랜잭션 클라이언트
* @param {string} postId - 게시물 ID
* @param {Array<string>} tags - 태그 슬러그 목록
* @returns {Promise<void>} 저장 결과
*/
const syncPostTags = async (sql, postId, tags) => {
const tagSlugs = normalizeTagSlugs(tags)
await sql`
DELETE FROM post_tags
WHERE post_id = ${postId}
`
for (const slug of tagSlugs) {
const tagRows = await sql`
INSERT INTO tags (name, slug)
VALUES (${getTagNameFromSlug(slug)}, ${slug})
ON CONFLICT (slug) DO UPDATE
SET updated_at = now()
RETURNING id
`
await sql`
INSERT INTO post_tags (post_id, tag_id)
VALUES (${postId}, ${tagRows[0].id})
ON CONFLICT DO NOTHING
`
}
}
/**
* 공개 게시물 목록 조회
* @returns {Promise<Array>} 게시물 목록
*/
export const listPosts = async () => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePosts()
}
const rows = await sql`
SELECT
posts.*,
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id
LEFT JOIN tags ON tags.id = post_tags.tag_id
WHERE posts.status = 'published'
AND (
posts.published_at IS NULL
OR posts.published_at <= now()
)
GROUP BY posts.id
ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC
`
return rows.map(mapPostRow)
}
/**
* 관리자 게시물 목록 조회
* @returns {Promise<Array>} 관리자 게시물 목록
*/
export const listAdminPosts = async () => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePosts()
}
const rows = await sql`
SELECT
posts.*,
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id
LEFT JOIN tags ON tags.id = post_tags.tag_id
GROUP BY posts.id
ORDER BY posts.updated_at DESC
`
return rows.map(mapPostRow)
}
/**
* 관리자 게시물 상세 조회
* @param {string} id - 게시물 ID
* @returns {Promise<Object | null>} 관리자 게시물 상세
*/
export const getAdminPostById = async (id) => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePosts().find((post) => post.id === id) || null
}
const rows = await sql`
SELECT
posts.*,
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id
LEFT JOIN tags ON tags.id = post_tags.tag_id
WHERE posts.id = ${id}
GROUP BY posts.id
LIMIT 1
`
return rows[0] ? mapPostRow(rows[0]) : null
}
/**
* 관리자 게시물 생성
* @param {Object} input - 게시물 입력값
* @returns {Promise<Object>} 생성된 게시물
*/
export const createAdminPost = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql.begin(async (transaction) => {
const insertedRows = await transaction`
INSERT INTO posts (
title,
slug,
content,
excerpt,
featured_image,
seo_title,
seo_description,
canonical_url,
noindex,
og_image,
status,
published_at
)
VALUES (
${input.title},
${input.slug},
${input.content},
${input.excerpt},
${input.featuredImage},
${input.seoTitle},
${input.seoDescription},
${input.canonicalUrl},
${input.noindex},
${input.ogImage},
${input.status},
${input.publishedAt}
)
RETURNING *
`
await syncPostTags(transaction, insertedRows[0].id, input.tags)
return insertedRows
})
return getAdminPostById(rows[0].id)
}
/**
* 관리자 게시물 수정
* @param {string} id - 게시물 ID
* @param {Object} input - 게시물 입력값
* @returns {Promise<Object | null>} 수정된 게시물
*/
export const updateAdminPost = async (id, input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql.begin(async (transaction) => {
const updatedRows = await transaction`
UPDATE posts
SET
title = ${input.title},
slug = ${input.slug},
content = ${input.content},
excerpt = ${input.excerpt},
featured_image = ${input.featuredImage},
seo_title = ${input.seoTitle},
seo_description = ${input.seoDescription},
canonical_url = ${input.canonicalUrl},
noindex = ${input.noindex},
og_image = ${input.ogImage},
status = ${input.status},
published_at = ${input.publishedAt},
updated_at = now()
WHERE id = ${id}
RETURNING *
`
if (!updatedRows[0]) {
return []
}
await syncPostTags(transaction, id, input.tags)
return updatedRows
})
return rows[0] ? getAdminPostById(rows[0].id) : null
}
/**
* 관리자 게시물 삭제
* @param {string} id - 게시물 ID
* @returns {Promise<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 - 게시물 슬러그
* @returns {Promise<Object | null>} 게시물 상세
*/
export const getPostBySlug = async (slug) => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePostBySlug(slug)
}
const rows = await sql`
SELECT
posts.*,
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id
LEFT JOIN tags ON tags.id = post_tags.tag_id
WHERE posts.slug = ${slug}
AND posts.status = 'published'
AND (
posts.published_at IS NULL
OR posts.published_at <= now()
)
GROUP BY posts.id
LIMIT 1
`
return rows[0] ? mapPostRow(rows[0]) : null
}
/**
* 공개 고정 페이지 목록 조회
* @returns {Promise<Array>} 고정 페이지 목록
*/
export const listPages = async () => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePages()
}
const rows = await sql`
SELECT *
FROM pages
ORDER BY created_at DESC
`
return rows.map(mapPageRow)
}
/**
* 관리자 고정 페이지 목록 조회
* @returns {Promise<Array>} 관리자 고정 페이지 목록
*/
export const listAdminPages = async () => listPages()
/**
* 관리자 고정 페이지 상세 조회
* @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
}
/**
* 관리자 고정 페이지 생성
* @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,
featured_image
)
VALUES (
${input.title},
${input.slug},
${input.content},
${input.featuredImage}
)
RETURNING *
`
return mapPageRow(rows[0])
}
/**
* 관리자 고정 페이지 수정
* @param {string} id - 페이지 ID
* @param {Object} input - 페이지 입력값
* @returns {Promise<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},
featured_image = ${input.featuredImage},
updated_at = now()
WHERE id = ${id}
RETURNING *
`
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}
LIMIT 1
`
return rows[0] ? mapPageRow(rows[0]) : null
}
/**
* 공개 태그 목록 조회
* @returns {Promise<Array>} 태그 목록
*/
export const listTags = async ({ tagType } = {}) => {
const sql = getPostgresClient()
if (!sql) {
const sampleTags = getSampleTags().map((tag) => ({
...tag,
tagType: 'managed'
}))
if (!tagType) {
return sampleTags
}
return sampleTags.filter((tag) => tag.tagType === tagType)
}
const rows = tagType
? await sql`
SELECT *
FROM tags
WHERE tag_type = ${tagType}
ORDER BY sort_order ASC, name ASC
`
: await sql`
SELECT *
FROM tags
ORDER BY
CASE tag_type WHEN 'managed' THEN 0 ELSE 1 END ASC,
sort_order ASC,
name ASC
`
return rows.map(mapTagRow)
}
/**
* 관리자 태그 목록 조회
* @returns {Promise<Array>} 관리자 태그 목록
*/
export const listAdminTags = async () => listTags()
const SEARCH_TAG_LIMIT = 12
const SEARCH_POST_LIMIT = 12
const SEARCH_POST_CANDIDATE_LIMIT = 48
/**
* 공개 검색에서 업로드 경로/파일명 같은 노이즈를 제거한다.
* - 마크다운 이미지 문법 `![](...)` 제거
* - 업로드 경로(`/uploads/...`) 제거
* @param {string} value - 원문
* @returns {string} 정규화된 문자열
*/
const normalizeSearchContent = (value) => String(value || '')
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
.replace(/\/uploads\/[^\s)"]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
/**
* 공개 검색: 태그·게시물 제목·요약·본문에서 부분 일치(대소문자 무시)
* @param {string} rawQuery - 검색어
* @returns {Promise<{ tags: Array<{ name: string, slug: string }>, posts: Array<{ slug: string, title: string, excerpt: string }> }>} 태그·게시물 요약 결과
*/
export const searchPublicContent = async (rawQuery) => {
const q = String(rawQuery || '').trim()
if (!q) {
return { tags: [], posts: [] }
}
const sql = getPostgresClient()
if (!sql) {
const needle = q.toLowerCase()
const posts = getSamplePosts()
.filter((post) => {
const hay = normalizeSearchContent(`${post.title}\n${post.excerpt || ''}\n${post.content || ''}`).toLowerCase()
return hay.includes(needle)
})
.slice(0, SEARCH_POST_LIMIT)
.map((post) => ({
slug: post.slug,
title: post.title,
excerpt: post.excerpt || ''
}))
const tags = getSampleTags()
.filter((tag) => {
const hay = `${tag.name}\n${tag.slug}`.toLowerCase()
return hay.includes(needle)
})
.slice(0, SEARCH_TAG_LIMIT)
.map((tag) => ({
name: tag.name,
slug: tag.slug
}))
return { tags, posts }
}
const tagRows = await sql`
SELECT name, slug
FROM tags
WHERE
position(lower(${q}) in lower(name)) > 0
OR position(lower(${q}) in lower(slug)) > 0
ORDER BY sort_order ASC, name ASC
LIMIT ${SEARCH_TAG_LIMIT}
`
const postRows = await sql`
SELECT posts.slug, posts.title, posts.excerpt, posts.content
FROM posts
WHERE posts.status = 'published'
AND (posts.published_at IS NULL OR posts.published_at <= now())
AND (
position(lower(${q}) in lower(posts.title)) > 0
OR position(lower(${q}) in lower(coalesce(posts.excerpt, ''))) > 0
OR position(lower(${q}) in lower(posts.content)) > 0
)
ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC
LIMIT ${SEARCH_POST_CANDIDATE_LIMIT}
`
const needle = q.toLowerCase()
const posts = postRows
.filter((row) => {
const hay = normalizeSearchContent(`${row.title}\n${row.excerpt || ''}\n${row.content || ''}`).toLowerCase()
return hay.includes(needle)
})
.slice(0, SEARCH_POST_LIMIT)
return {
tags: tagRows.map((row) => ({ name: row.name, slug: row.slug })),
posts: posts.map((row) => ({
slug: row.slug,
title: row.title,
excerpt: row.excerpt || ''
}))
}
}
/**
* 사이트 설정 조회
* @returns {Promise<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,
copyright_text,
updated_at
)
VALUES (
1,
${input.title},
${input.description},
${input.siteUrl},
${input.logoText},
${input.copyrightText},
now()
)
ON CONFLICT (id) DO UPDATE
SET
title = EXCLUDED.title,
description = EXCLUDED.description,
site_url = EXCLUDED.site_url,
logo_text = EXCLUDED.logo_text,
copyright_text = EXCLUDED.copyright_text,
updated_at = now()
RETURNING *
`
return mapSiteSettingsRow(rows[0])
}
/**
* 네비게이션 항목 목록 조회
* @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>}>} 위치별 공개 네비게이션
*/
export const getPublicNavigation = async () => groupNavigationItems(await listNavigationItems({ visibleOnly: true }))
/**
* 관리자 네비게이션 항목 일괄 저장
* @param {Array<Object>} items - 저장할 네비게이션 항목 목록
* @returns {Promise<Array>} 저장된 네비게이션 항목 목록
*/
export const updateNavigationItems = async (items) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
await sql.begin(async (transaction) => {
await transaction`
DELETE FROM navigation_items
`
for (const item of items) {
await transaction`
INSERT INTO navigation_items (
label,
url,
location,
sort_order,
is_visible
)
VALUES (
${item.label},
${item.url},
${item.location},
${item.sortOrder},
${item.isVisible}
)
`
}
})
return listNavigationItems()
}
/**
* 관리자 태그 상세 조회
* @param {string} id - 태그 ID
* @returns {Promise<Object | null>} 태그 상세
*/
export const getAdminTagById = async (id) => {
const sql = getPostgresClient()
if (!sql) {
return getSampleTags().find((tag) => tag.id === id) || null
}
const rows = await sql`
SELECT *
FROM tags
WHERE id = ${id}
LIMIT 1
`
return rows[0] ? mapTagRow(rows[0]) : null
}
/**
* 관리자 태그 생성
* @param {Object} input - 태그 입력값
* @returns {Promise<Object>} 생성된 태그
*/
export const createAdminTag = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
INSERT INTO tags (name, slug, description, sort_order, color, tag_type)
VALUES (${input.name}, ${input.slug}, ${input.description}, ${input.sortOrder}, ${input.color}, ${input.tagType})
RETURNING *
`
return mapTagRow(rows[0])
}
/**
* 관리자 태그 수정
* @param {string} id - 태그 ID
* @param {Object} input - 태그 입력값
* @returns {Promise<Object | null>} 수정된 태그
*/
export const updateAdminTag = async (id, input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
UPDATE tags
SET
name = ${input.name},
slug = ${input.slug},
description = ${input.description},
sort_order = ${input.sortOrder},
color = ${input.color},
tag_type = ${input.tagType},
updated_at = now()
WHERE id = ${id}
RETURNING *
`
return rows[0] ? mapTagRow(rows[0]) : null
}
/**
* 관리자 관리용 태그 순서를 일괄 갱신
* @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])
}