@restnfeel/agentc-starter-kit
Version:
한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템
578 lines (526 loc) • 14.5 kB
text/typescript
import { PrismaClient, Prisma } from "@prisma/client";
import { createSiteFromTemplate } from "@/templates";
const prisma = new PrismaClient();
// Site 관련 타입 정의
export interface CreateSiteData {
name: string;
slug: string;
domain?: string;
subdomain?: string;
description?: string;
templateId: string;
ownerId: string;
}
export interface UpdateSiteData {
name?: string;
description?: string;
domain?: string;
subdomain?: string;
status?: "DRAFT" | "ACTIVE" | "INACTIVE" | "ARCHIVED";
isPublished?: boolean;
settings?: Record<string, unknown>;
templateData?: Record<string, unknown>;
}
export interface SiteListOptions {
ownerId?: string;
status?: "DRAFT" | "ACTIVE" | "INACTIVE" | "ARCHIVED";
search?: string;
page?: number;
pageSize?: number;
sortBy?: "name" | "createdAt" | "updatedAt";
sortOrder?: "asc" | "desc";
}
export interface Site {
id: string;
name: string;
slug: string;
domain?: string | null;
subdomain?: string | null;
description?: string | null;
templateId: string;
templateData?: Record<string, unknown> | null;
status: "DRAFT" | "ACTIVE" | "INACTIVE" | "ARCHIVED";
isPublished: boolean;
publishedAt?: Date | null;
ownerId: string;
settings?: Record<string, unknown> | null;
createdAt: Date;
updatedAt: Date;
_count?: {
siteSections: number;
pages: number;
assets: number;
siteUsers: number;
};
}
export class SiteApiService {
// 사이트 목록 조회
async getSites(options: SiteListOptions = {}): Promise<{
sites: Site[];
totalCount: number;
page: number;
pageSize: number;
totalPages: number;
}> {
const {
ownerId,
status,
search,
page = 1,
pageSize = 20,
sortBy = "updatedAt",
sortOrder = "desc",
} = options;
// 기본 WHERE 조건
const where: {
ownerId?: string;
status?: "DRAFT" | "ACTIVE" | "INACTIVE" | "ARCHIVED";
OR?: Array<{
name?: { contains: string; mode: "insensitive" };
description?: { contains: string; mode: "insensitive" };
domain?: { contains: string; mode: "insensitive" };
subdomain?: { contains: string; mode: "insensitive" };
}>;
} = {};
if (ownerId) {
where.ownerId = ownerId;
}
if (status) {
where.status = status;
}
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ description: { contains: search, mode: "insensitive" } },
{ domain: { contains: search, mode: "insensitive" } },
{ subdomain: { contains: search, mode: "insensitive" } },
];
}
// 총 개수 조회
const totalCount = await prisma.site.count({ where });
// 페이지네이션 계산
const skip = (page - 1) * pageSize;
const totalPages = Math.ceil(totalCount / pageSize);
// 사이트 목록 조회
const sites = (await prisma.site.findMany({
where,
skip,
take: pageSize,
orderBy: { [sortBy]: sortOrder },
include: {
_count: {
select: {
siteSections: true,
pages: true,
assets: true,
siteUsers: true,
},
},
},
})) as Site[];
return {
sites,
totalCount,
page,
pageSize,
totalPages,
};
}
// 사이트 상세 조회
async getSite(
siteId: string,
includeRelations = false
): Promise<Site | null> {
const site = await prisma.site.findUnique({
where: { id: siteId },
include: includeRelations
? {
siteSections: {
orderBy: { order: "asc" },
},
pages: {
orderBy: { createdAt: "desc" },
},
siteUsers: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
_count: {
select: {
siteSections: true,
pages: true,
assets: true,
siteUsers: true,
},
},
}
: {
_count: {
select: {
siteSections: true,
pages: true,
assets: true,
siteUsers: true,
},
},
},
});
return site as Site | null;
}
// Slug로 사이트 조회
async getSiteBySlug(slug: string): Promise<Site | null> {
const site = await prisma.site.findUnique({
where: { slug },
include: {
_count: {
select: {
siteSections: true,
pages: true,
assets: true,
siteUsers: true,
},
},
},
});
return site as Site | null;
}
// 도메인으로 사이트 조회
async getSiteByDomain(domain: string): Promise<Site | null> {
const site = await prisma.site.findFirst({
where: {
OR: [{ domain }, { subdomain: domain }],
},
include: {
_count: {
select: {
siteSections: true,
pages: true,
assets: true,
siteUsers: true,
},
},
},
});
return site as Site | null;
}
// 사이트 생성
async createSite(data: CreateSiteData): Promise<Site> {
// Slug 중복 확인
const existingSite = await this.getSiteBySlug(data.slug);
if (existingSite) {
throw new Error(`사이트 슬러그 '${data.slug}'는 이미 사용 중입니다.`);
}
// 도메인 중복 확인
if (data.domain) {
const existingDomain = await this.getSiteByDomain(data.domain);
if (existingDomain) {
throw new Error(`도메인 '${data.domain}'는 이미 사용 중입니다.`);
}
}
// 템플릿 기반 사이트 데이터 생성
const siteFromTemplate = createSiteFromTemplate(data.templateId, {
name: data.name,
slug: data.slug,
domain: data.domain,
});
// 사이트 생성
const site = await prisma.site.create({
data: {
name: data.name,
slug: data.slug,
domain: data.domain,
subdomain: data.subdomain,
description: data.description,
templateId: data.templateId,
templateData: siteFromTemplate.templateData as Prisma.InputJsonValue,
ownerId: data.ownerId,
settings: siteFromTemplate.settings,
status: "DRAFT",
isPublished: false,
},
include: {
_count: {
select: {
siteSections: true,
pages: true,
assets: true,
siteUsers: true,
},
},
},
});
// 사이트 섹션 생성
for (const sectionData of siteFromTemplate.sections) {
await prisma.siteSection.create({
data: {
siteId: site.id,
type: sectionData.type,
name: sectionData.name,
content: sectionData.content,
styles: sectionData.styles,
order: sectionData.order,
isVisible: sectionData.isVisible,
isPublished: sectionData.isPublished,
},
});
}
// 기본 페이지 생성
await prisma.sitePage.create({
data: {
siteId: site.id,
title: data.name,
slug: "home",
path: "/",
description: data.description || `${data.name} 홈페이지`,
metaTitle: data.name,
metaDescription: data.description,
isPublished: false,
isDraft: true,
},
});
// 사이트 소유자를 OWNER 역할로 추가
await prisma.siteUser.create({
data: {
siteId: site.id,
userId: data.ownerId,
role: "OWNER",
isActive: true,
joinedAt: new Date(),
},
});
return site as Site;
}
// 사이트 업데이트
async updateSite(siteId: string, data: UpdateSiteData): Promise<Site> {
// 도메인 중복 확인 (변경되는 경우)
if (data.domain) {
const existingDomain = await prisma.site.findFirst({
where: {
AND: [
{ id: { not: siteId } },
{
OR: [{ domain: data.domain }, { subdomain: data.domain }],
},
],
},
});
if (existingDomain) {
throw new Error(`도메인 '${data.domain}'는 이미 사용 중입니다.`);
}
}
const site = await prisma.site.update({
where: { id: siteId },
data: {
...data,
...(data.templateData !== undefined
? { templateData: data.templateData as Prisma.InputJsonValue }
: {}),
updatedAt: new Date(),
} as any,
include: {
_count: {
select: {
siteSections: true,
pages: true,
assets: true,
siteUsers: true,
},
},
},
});
return site as Site;
}
// 사이트 삭제
async deleteSite(siteId: string): Promise<void> {
// 관련 데이터들이 cascade delete로 자동 삭제됨
await prisma.site.delete({
where: { id: siteId },
});
}
// 사이트 발행
async publishSite(siteId: string): Promise<Site> {
const site = await prisma.site.update({
where: { id: siteId },
data: {
isPublished: true,
publishedAt: new Date(),
status: "ACTIVE",
},
include: {
_count: {
select: {
siteSections: true,
pages: true,
assets: true,
siteUsers: true,
},
},
},
});
return site as Site;
}
// 사이트 발행 취소
async unpublishSite(siteId: string): Promise<Site> {
const site = await prisma.site.update({
where: { id: siteId },
data: {
isPublished: false,
status: "DRAFT",
},
include: {
_count: {
select: {
siteSections: true,
pages: true,
assets: true,
siteUsers: true,
},
},
},
});
return site as Site;
}
// 사이트 복제
async cloneSite(
siteId: string,
newData: {
name: string;
slug: string;
ownerId: string;
}
): Promise<Site> {
const originalSite = await this.getSite(siteId, true);
if (!originalSite) {
throw new Error("원본 사이트를 찾을 수 없습니다.");
}
// 새로운 사이트 생성
const createData: CreateSiteData = {
name: newData.name,
slug: newData.slug,
ownerId: newData.ownerId,
templateId: originalSite.templateId,
description: originalSite.description || undefined,
domain: undefined,
subdomain: undefined,
};
return await this.createSite(createData);
}
// 사이트 통계
async getSiteStats(ownerId?: string): Promise<{
total: number;
published: number;
draft: number;
active: number;
inactive: number;
byTemplate: Record<string, number>;
}> {
const where = ownerId ? { ownerId } : {};
const [total, published, draft, active, inactive, byTemplate] =
await Promise.all([
prisma.site.count({ where }),
prisma.site.count({ where: { ...where, isPublished: true } }),
prisma.site.count({ where: { ...where, status: "DRAFT" } }),
prisma.site.count({ where: { ...where, status: "ACTIVE" } }),
prisma.site.count({ where: { ...where, status: "INACTIVE" } }),
prisma.site.groupBy({
by: ["templateId"],
where,
_count: { id: true },
}),
]);
const templateStats = byTemplate.reduce(
(
acc: Record<string, number>,
item: { templateId: string; _count: { id: number } }
) => {
acc[item.templateId] = item._count.id;
return acc;
},
{} as Record<string, number>
);
return {
total,
published,
draft,
active,
inactive,
byTemplate: templateStats,
};
}
// 사이트 검색 (자동완성용)
async searchSites(
query: string,
ownerId?: string,
limit = 10
): Promise<
{
id: string;
name: string;
slug: string;
domain?: string | null;
}[]
> {
const where: {
ownerId?: string;
OR?: Array<{
name?: { contains: string; mode: "insensitive" };
slug?: { contains: string; mode: "insensitive" };
domain?: { contains: string; mode: "insensitive" };
}>;
} = {
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ slug: { contains: query, mode: "insensitive" } },
{ domain: { contains: query, mode: "insensitive" } },
],
};
if (ownerId) {
where.ownerId = ownerId;
}
const sites = await prisma.site.findMany({
where,
select: {
id: true,
name: true,
slug: true,
domain: true,
},
take: limit,
orderBy: { updatedAt: "desc" },
});
return sites;
}
}
// 전역 Site API 서비스 인스턴스
export const siteApiService = new SiteApiService();
// 유틸리티 함수들
export const createSite = (data: CreateSiteData) =>
siteApiService.createSite(data);
export const getSites = (options?: SiteListOptions) =>
siteApiService.getSites(options);
export const getSite = (id: string, includeRelations?: boolean) =>
siteApiService.getSite(id, includeRelations);
export const getSiteBySlug = (slug: string) =>
siteApiService.getSiteBySlug(slug);
export const getSiteByDomain = (domain: string) =>
siteApiService.getSiteByDomain(domain);
export const updateSite = (id: string, data: UpdateSiteData) =>
siteApiService.updateSite(id, data);
export const deleteSite = (id: string) => siteApiService.deleteSite(id);
export const publishSite = (id: string) => siteApiService.publishSite(id);
export const unpublishSite = (id: string) => siteApiService.unpublishSite(id);
export const cloneSite = (
id: string,
newData: Parameters<typeof siteApiService.cloneSite>[1]
) => siteApiService.cloneSite(id, newData);
export const getSiteStats = (ownerId?: string) =>
siteApiService.getSiteStats(ownerId);
export const searchSites = (query: string, ownerId?: string, limit?: number) =>
siteApiService.searchSites(query, ownerId, limit);