diff --git a/7.0.1 b/7.0.1 new file mode 100644 index 0000000..e69de29 diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/page-builder@1.0.0 b/page-builder@1.0.0 new file mode 100644 index 0000000..e69de29 diff --git a/playwright b/playwright new file mode 100644 index 0000000..e69de29 diff --git a/prisma/migrations/20251201230729_add_form_submission/migration.sql b/prisma/migrations/20251201230729_add_form_submission/migration.sql new file mode 100644 index 0000000..0307188 --- /dev/null +++ b/prisma/migrations/20251201230729_add_form_submission/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "FormSubmission" ( + "id" TEXT NOT NULL, + "projectId" TEXT, + "projectSlug" TEXT NOT NULL, + "userId" TEXT, + "payloadJson" JSONB NOT NULL, + "sensitiveEnc" TEXT, + "metaJson" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FormSubmission_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "FormSubmission" ADD CONSTRAINT "FormSubmission_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FormSubmission" ADD CONSTRAINT "FormSubmission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d5ef0ad..f2cf435 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,6 +25,8 @@ model Project { assets Asset[] + formSubmissions FormSubmission[] + // 인증 도입을 위한 관계: 프로젝트는 특정 User 가 소유할 수 있다. userId String? user User? @relation(fields: [userId], references: [id]) @@ -39,6 +41,8 @@ model User { updatedAt DateTime @updatedAt projects Project[] + + formSubmissions FormSubmission[] } model Asset { @@ -57,3 +61,16 @@ enum ProjectStatus { PUBLISHED ARCHIVED } + +model FormSubmission { + id String @id @default(uuid()) + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + projectId String? + projectSlug String + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + userId String? + payloadJson Json + sensitiveEnc String? + metaJson Json? + createdAt DateTime @default(now()) +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index b216c8a..8da750a 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -43,6 +43,7 @@ export async function POST(request: Request) { secure: true, sameSite: "lax", path: "/", + maxAge: 7 * 24 * 60 * 60, }); return res; diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index 2cab297..9c4b72e 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -58,6 +58,7 @@ export async function POST(request: Request) { secure: true, sameSite: "lax", path: "/", + maxAge: 7 * 24 * 60 * 60, }); return res; diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 7c4b7df..64becc3 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -365,11 +365,19 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const configJson = JSON.stringify(props ?? {}); const escapedConfig = escapeAttr(configJson); + const projectSlugRaw = (projectConfig?.slug ?? "").trim(); + const projectSlugValue = projectSlugRaw !== "" ? projectSlugRaw : ""; + const parts: string[] = []; parts.push( `
`, ); parts.push(``); + if (projectSlugValue) { + parts.push( + ``, + ); + } parts.push("
"); return parts.join(""); } diff --git a/src/app/api/forms/submit/route.ts b/src/app/api/forms/submit/route.ts index 8e70f5c..3ec80f9 100644 --- a/src/app/api/forms/submit/route.ts +++ b/src/app/api/forms/submit/route.ts @@ -1,15 +1,158 @@ import { NextResponse } from "next/server"; -import type { FormBlockProps } from "@/features/editor/state/editorStore"; +import { PrismaClient } from "@prisma/client"; +import type { FormBlockProps, FormFieldConfig } from "@/features/editor/state/editorStore"; +import { encryptJson } from "@/features/auth/authCrypto"; + +const prisma = new PrismaClient() as any; + +function buildSensitiveFieldNameSet(config: FormBlockProps | null): Set { + const result = new Set(); + const fields: FormFieldConfig[] = Array.isArray(config?.fields) ? (config!.fields as FormFieldConfig[]) : []; + + for (const field of fields) { + const name = (field.name ?? "").toLowerCase(); + const label = (field.label ?? "").toLowerCase(); + if (!name) continue; + + const isEmail = + field.type === "email" || + name.includes("email") || + label.includes("email") || + label.includes("이메일"); + + if (isEmail) { + result.add(field.name); + continue; + } + + const isPhone = + name.includes("phone") || + name.includes("tel") || + name.includes("mobile") || + label.includes("전화") || + label.includes("연락처") || + label.includes("휴대폰"); + + if (isPhone) { + result.add(field.name); + continue; + } + + const isBirth = + name.includes("birth") || + name.includes("birthday") || + name.includes("dob") || + label.includes("생년월일") || + label.includes("생일"); + + if (isBirth) { + result.add(field.name); + continue; + } + } + + return result; +} + +async function persistFormSubmission(formData: FormData, config: FormBlockProps | null) { + const projectSlugRaw = formData.get("__projectSlug"); + let projectSlug: string | null = null; + + if (typeof projectSlugRaw === "string" && projectSlugRaw.trim() !== "") { + projectSlug = projectSlugRaw.trim(); + } else if (config?.extraParams && typeof config.extraParams.projectSlug === "string") { + const v = config.extraParams.projectSlug.trim(); + if (v !== "") { + projectSlug = v; + } + } + + const sensitiveNames = buildSensitiveFieldNameSet(config); + + formData.forEach((_, key) => { + if (key === "__config" || key === "__projectSlug") { + return; + } + + const lower = key.toLowerCase(); + + const isEmailField = + lower.includes("email") || + lower.includes("e-mail") || + lower.includes("이메일"); + + const isPhoneField = + lower.includes("phone") || + lower.includes("tel") || + lower.includes("mobile") || + lower.includes("전화") || + lower.includes("연락처") || + lower.includes("휴대폰"); + + const isBirthField = + lower.includes("birth") || + lower.includes("birthday") || + lower.includes("birthdate") || + lower.includes("dob") || + lower.includes("생년월일") || + lower.includes("생일"); + + if (isEmailField || isPhoneField || isBirthField) { + sensitiveNames.add(key); + } + }); + const payloadJson: Record = {}; + const sensitivePayload: Record = {}; + + formData.forEach((value, key) => { + if (key === "__config" || key === "__projectSlug") { + return; + } + + const strValue = String(value); + + if (sensitiveNames.has(key)) { + sensitivePayload[key] = strValue; + } else { + payloadJson[key] = strValue; + } + }); + + let sensitiveEnc: string | null = null; + if (Object.keys(sensitivePayload).length > 0) { + sensitiveEnc = await encryptJson(sensitivePayload); + } + + let projectId: string | null = null; + let userId: string | null = null; + + if (projectSlug) { + const project = await prisma.project.findUnique({ where: { slug: projectSlug } }); + if (project) { + projectId = project.id; + userId = project.userId ?? null; + } + } + + await prisma.formSubmission.create({ + data: { + projectSlug: projectSlug ?? "", + projectId, + userId, + payloadJson, + sensitiveEnc, + metaJson: null, + }, + }); +} export async function POST(req: Request) { const formData = await req.formData(); - // 기본 필드(name/email/message)는 그대로 읽어둔다. const name = formData.get("name"); const email = formData.get("email"); const message = formData.get("message"); - // 에디터에서 넘겨준 폼 설정(JSON) 읽기 const rawConfig = formData.get("__config"); let config: FormBlockProps | null = null; if (typeof rawConfig === "string") { @@ -24,18 +167,17 @@ export async function POST(req: Request) { const successMessage = config?.successMessage; const errorMessage = config?.errorMessage; - // 1) internal: 우리 서버에서 직접 처리하는 기본 모드 if (submitTarget === "internal") { - // TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다. + await persistFormSubmission(formData, config); console.log("[forms/submit][internal]", { name, email, message }); return NextResponse.json({ ok: true, message: successMessage }); } if (submitTarget === "both") { + await persistFormSubmission(formData, config); console.log("[forms/submit][internal]", { name, email, message }); } - // 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함) if (submitTarget === "webhook" || submitTarget === "both") { const destinationUrl = config?.destinationUrl; if (!destinationUrl) { diff --git a/src/app/api/projects/[slug]/submissions/route.ts b/src/app/api/projects/[slug]/submissions/route.ts new file mode 100644 index 0000000..aee2fc7 --- /dev/null +++ b/src/app/api/projects/[slug]/submissions/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server"; +import { PrismaClient } from "@prisma/client"; +import { verifyAccessToken, decryptJson } from "@/features/auth/authCrypto"; + +const prisma = new PrismaClient(); + +interface AuthUser { + id: string; + email: string; + tokenVersion: number; +} + +function extractTokenFromCookieHeader(cookieHeader: string | null): string | null { + if (!cookieHeader) return null; + + const parts = cookieHeader.split(";"); + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed.startsWith("pb_access=")) { + const value = trimmed.substring("pb_access=".length); + return decodeURIComponent(value); + } + } + return null; +} + +async function getAuthUserFromRequest(request: Request): Promise { + const cookieHeader = request.headers.get("cookie"); + const token = extractTokenFromCookieHeader(cookieHeader); + if (!token) return null; + + const payload = await verifyAccessToken(token); + if (!payload) return null; + + return { + id: payload.sub, + email: payload.email, + tokenVersion: payload.tokenVersion, + }; +} + +export async function GET( + request: Request, + context: { params: { slug: string } | Promise<{ slug: string }> }, +) { + const authUser = await getAuthUserFromRequest(request); + if (!authUser) { + return NextResponse.json({ message: "폼 제출 내역을 조회하려면 로그인이 필요합니다." }, { status: 401 }); + } + + const { slug } = await context.params; + + const project = await prisma.project.findUnique({ + where: { slug }, + }); + + if (!project || (project.userId && project.userId !== authUser.id)) { + return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 }); + } + + const submissions = await prisma.formSubmission.findMany({ + where: { projectId: project.id }, + orderBy: { createdAt: "desc" }, + take: 100, + }); + + const items = await Promise.all( + submissions.map(async (s: any) => { + const basePayload = (s.payloadJson ?? {}) as Record; + let sensitive: Record = {}; + + if (s.sensitiveEnc) { + try { + sensitive = (await decryptJson>(s.sensitiveEnc)) ?? {}; + } catch { + sensitive = {}; + } + } + + const payload = { ...basePayload, ...sensitive }; + + return { + id: s.id, + createdAt: s.createdAt, + projectSlug: s.projectSlug, + payload, + }; + }), + ); + + return NextResponse.json(items, { status: 200 }); +} diff --git a/src/app/preview/page.tsx b/src/app/preview/page.tsx index 6c1a4e2..6f8ef4c 100644 --- a/src/app/preview/page.tsx +++ b/src/app/preview/page.tsx @@ -39,7 +39,22 @@ export default function PreviewPage() { useEffect(() => { if (typeof window === "undefined") return; - const slug = (projectConfig as any)?.slug?.trim?.(); + let slug = (projectConfig as any)?.slug?.trim?.(); + + if (!slug) { + try { + const url = new URL(window.location.href); + const fromQuery = url.searchParams.get("slug")?.trim(); + + if (fromQuery && fromQuery !== slug) { + slug = fromQuery; + updateProjectConfig({ slug: fromQuery } as any); + } + } catch { + // URL 파싱 오류는 무시하고, autosave 복원 없이 진행한다. + } + } + if (!slug) return; const key = `pb:autosave:${slug}`; @@ -163,7 +178,10 @@ export default function PreviewPage() { className="w-full" style={canvasStyle} > - + diff --git a/src/app/projects/[slug]/submissions/page.tsx b/src/app/projects/[slug]/submissions/page.tsx new file mode 100644 index 0000000..0245957 --- /dev/null +++ b/src/app/projects/[slug]/submissions/page.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useParams } from "next/navigation"; + +// 프로젝트별 폼 제출 내역을 조회해서 보여주는 페이지 컴포넌트. +// - URL 의 slug 파라미터를 기준으로 /api/projects/[slug]/submissions 에 요청을 보낸다. +// - 401 이면 로그인 페이지로 이동시키고, 404/500 계열 에러는 한국어 에러 메시지로 안내한다. +// - 응답 payload 의 name/email/phone/birthdate 는 개별 컬럼으로, 나머지는 "기타 필드" 텍스트로 렌더링한다. + +interface SubmissionItem { + id: string; + createdAt: string; + projectSlug: string; + // 서버에서 복호화된 폼 필드 전체가 payload 로 내려온다. + payload: Record; +} + +type PageStatus = "idle" | "loading" | "error"; + +export default function ProjectSubmissionsPage() { + const router = useRouter(); + const params = useParams() as { slug?: string } | null; + const slug = (params?.slug ?? "").toString(); + + const [submissions, setSubmissions] = useState([]); + const [status, setStatus] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(null); + + // 마운트 시 현재 slug 기준으로 폼 제출 내역을 불러온다. + useEffect(() => { + if (!slug) return; + + let cancelled = false; + + const load = async () => { + try { + setStatus("loading"); + setErrorMessage(null); + + const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/submissions`); + + if (!res.ok) { + if (cancelled) return; + + if (res.status === 401) { + setStatus("error"); + setErrorMessage("폼 제출 내역을 조회하려면 로그인이 필요합니다. 다시 로그인해 주세요."); + router.push("/login"); + return; + } + + if (res.status === 404) { + setStatus("error"); + setErrorMessage( + "프로젝트를 찾을 수 없거나 접근 권한이 없습니다. 프로젝트 소유자 계정으로 다시 로그인해 주세요.", + ); + return; + } + + setStatus("error"); + setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."); + return; + } + + const data = (await res.json()) as SubmissionItem[]; + + if (!cancelled) { + setSubmissions(Array.isArray(data) ? data : []); + setStatus("idle"); + setErrorMessage(null); + } + } catch { + if (!cancelled) { + setStatus("error"); + setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."); + } + } + }; + + void load(); + + return () => { + cancelled = true; + }; + }, [slug]); + + // payload 에서 name/email/phone/birthdate 를 제외한 나머지 필드를 "기타" 영역으로 모아 보여준다. + const renderOtherFields = (payload: Record): string => { + const knownKeys = new Set(["name", "email", "phone", "birthdate"]); + const entries = Object.entries(payload).filter(([key]) => !knownKeys.has(key)); + + if (entries.length === 0) { + return "-"; + } + + return entries + .map(([key, value]) => { + const stringValue = typeof value === "string" ? value : JSON.stringify(value); + return `${key}: ${stringValue}`; + }) + .join("\n"); + }; + + return ( +
+
+
+

폼 제출 내역

+

프로젝트에 연결된 폼으로 수집된 제출 데이터를 확인할 수 있습니다.

+
+
+ {slug} +
+
+ +
+ {status === "error" && errorMessage && ( +

{errorMessage}

+ )} + + {status === "loading" && ( +

폼 제출 내역을 불러오는 중입니다...

+ )} + + {submissions.length === 0 && status === "idle" && !errorMessage && ( +

아직 수집된 폼 제출 내역이 없습니다.

+ )} + + {submissions.length > 0 && ( + + + + + + + + + + + + + {submissions.map((item) => { + const payload = (item.payload ?? {}) as Record; + + const nameValue = payload["name"]; + const emailValue = payload["email"]; + const phoneValue = payload["phone"]; + const birthdateValue = payload["birthdate"]; + + const name = typeof nameValue === "string" ? nameValue : ""; + const email = typeof emailValue === "string" ? emailValue : ""; + const phone = typeof phoneValue === "string" ? phoneValue : ""; + const birthdate = typeof birthdateValue === "string" ? birthdateValue : ""; + + return ( + + + + + + + + + ); + })} + +
제출 시각이름이메일전화번호생년월일기타 필드
+ {new Date(item.createdAt).toLocaleString()} + {name}{email}{phone}{birthdate} + {renderOtherFields(payload)} +
+ )} +
+
+ ); +} diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 1d85a39..eb7757a 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -330,6 +330,13 @@ export default function ProjectsPage() {