스키마 작업완료

This commit is contained in:
koreacomp5
2025-10-09 11:23:27 +09:00
parent feea2efd7d
commit 296df7b582
10 changed files with 1150 additions and 111 deletions

5
package-lock.json generated
View File

@@ -11,7 +11,8 @@
"@prisma/client": "^6.17.0",
"next": "15.5.4",
"react": "19.1.0",
"react-dom": "19.1.0"
"react-dom": "19.1.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@biomejs/biome": "2.2.0",
@@ -7016,9 +7017,7 @@
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -17,6 +17,7 @@
},
"dependencies": {
"@prisma/client": "^6.17.0",
"zod": "^3.23.8",
"next": "15.5.4",
"react": "19.1.0",
"react-dom": "19.1.0"

View File

@@ -0,0 +1,466 @@
/*
Warnings:
- You are about to drop the `Message` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `User` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "Message";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "User";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "boards" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'active',
"type" TEXT NOT NULL DEFAULT 'general',
"requiresApproval" BOOLEAN NOT NULL DEFAULT false,
"allowAnonymousPost" BOOLEAN NOT NULL DEFAULT false,
"allowSecretComment" BOOLEAN NOT NULL DEFAULT false,
"isAdultOnly" BOOLEAN NOT NULL DEFAULT false,
"requiredTags" JSONB,
"requiredFields" JSONB,
"readLevel" TEXT NOT NULL DEFAULT 'public',
"writeLevel" TEXT NOT NULL DEFAULT 'member',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "board_moderators" (
"id" TEXT NOT NULL PRIMARY KEY,
"boardId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'MODERATOR',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "board_moderators_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "boards" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "board_moderators_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("userId") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "posts" (
"id" TEXT NOT NULL PRIMARY KEY,
"boardId" TEXT NOT NULL,
"authorId" TEXT,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'published',
"isAnonymous" BOOLEAN NOT NULL DEFAULT false,
"isPinned" BOOLEAN NOT NULL DEFAULT false,
"pinnedOrder" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"lastActivityAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "posts_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "boards" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "posts_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users" ("userId") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "users" (
"userId" TEXT NOT NULL PRIMARY KEY,
"nickname" TEXT NOT NULL,
"passwordHash" TEXT,
"name" TEXT NOT NULL,
"birth" DATETIME NOT NULL,
"phone" TEXT NOT NULL,
"rank" INTEGER NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'active',
"authLevel" TEXT NOT NULL DEFAULT 'USER',
"profileImage" TEXT,
"lastLoginAt" DATETIME,
"isAdultVerified" BOOLEAN NOT NULL DEFAULT false,
"loginFailCount" INTEGER NOT NULL DEFAULT 0,
"agreementTermsAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "roles" (
"roleId" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "user_roles" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_roles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("userId") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "user_roles_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles" ("roleId") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "comments" (
"id" TEXT NOT NULL PRIMARY KEY,
"postId" TEXT NOT NULL,
"authorId" TEXT,
"content" TEXT NOT NULL,
"isAnonymous" BOOLEAN NOT NULL DEFAULT false,
"isSecret" BOOLEAN NOT NULL DEFAULT false,
"secretPasswordHash" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "comments_postId_fkey" FOREIGN KEY ("postId") REFERENCES "posts" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "comments_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users" ("userId") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "tags" (
"tagId" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "post_tags" (
"id" TEXT NOT NULL PRIMARY KEY,
"postId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
CONSTRAINT "post_tags_postId_fkey" FOREIGN KEY ("postId") REFERENCES "posts" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "post_tags_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "tags" ("tagId") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "attachments" (
"id" TEXT NOT NULL PRIMARY KEY,
"postId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'image',
"size" INTEGER,
"width" INTEGER,
"height" INTEGER,
"sortOrder" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "attachments_postId_fkey" FOREIGN KEY ("postId") REFERENCES "posts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "reactions" (
"id" TEXT NOT NULL PRIMARY KEY,
"postId" TEXT NOT NULL,
"userId" TEXT,
"clientHash" TEXT,
"type" TEXT NOT NULL DEFAULT 'RECOMMEND',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "reactions_postId_fkey" FOREIGN KEY ("postId") REFERENCES "posts" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "reactions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("userId") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "post_view_logs" (
"id" TEXT NOT NULL PRIMARY KEY,
"postId" TEXT NOT NULL,
"userId" TEXT,
"ip" TEXT,
"userAgent" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "post_view_logs_postId_fkey" FOREIGN KEY ("postId") REFERENCES "posts" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "post_view_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("userId") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "post_stats" (
"postId" TEXT NOT NULL PRIMARY KEY,
"views" INTEGER NOT NULL DEFAULT 0,
"recommendCount" INTEGER NOT NULL DEFAULT 0,
"reportCount" INTEGER NOT NULL DEFAULT 0,
"commentsCount" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "post_stats_postId_fkey" FOREIGN KEY ("postId") REFERENCES "posts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "reports" (
"id" TEXT NOT NULL PRIMARY KEY,
"reporterId" TEXT,
"targetType" TEXT NOT NULL,
"postId" TEXT,
"commentId" TEXT,
"reason" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'open',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "reports_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "users" ("userId") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "reports_postId_fkey" FOREIGN KEY ("postId") REFERENCES "posts" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "reports_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "comments" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "messages" (
"id" TEXT NOT NULL PRIMARY KEY,
"senderId" TEXT NOT NULL,
"receiverId" TEXT NOT NULL,
"body" TEXT NOT NULL,
"readAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "messages_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "users" ("userId") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "messages_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "users" ("userId") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "blocks" (
"id" TEXT NOT NULL PRIMARY KEY,
"blockerId" TEXT NOT NULL,
"blockedId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "blocks_blockerId_fkey" FOREIGN KEY ("blockerId") REFERENCES "users" ("userId") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "blocks_blockedId_fkey" FOREIGN KEY ("blockedId") REFERENCES "users" ("userId") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "point_transactions" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"referenceType" TEXT,
"referenceId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "point_transactions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("userId") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "level_thresholds" (
"id" TEXT NOT NULL PRIMARY KEY,
"level" INTEGER NOT NULL,
"minPoints" INTEGER NOT NULL
);
-- CreateTable
CREATE TABLE "banned_keywords" (
"id" TEXT NOT NULL PRIMARY KEY,
"pattern" TEXT NOT NULL,
"appliesTo" TEXT NOT NULL,
"severity" INTEGER NOT NULL DEFAULT 1,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "sanctions" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"startAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endAt" DATETIME,
"createdBy" TEXT,
CONSTRAINT "sanctions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("userId") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL PRIMARY KEY,
"actorId" TEXT,
"action" TEXT NOT NULL,
"targetType" TEXT,
"targetId" TEXT,
"meta" JSONB,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "users" ("userId") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "admin_notifications" (
"id" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL,
"message" TEXT NOT NULL,
"targetType" TEXT,
"targetId" TEXT,
"createdBy" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"readAt" DATETIME,
CONSTRAINT "admin_notifications_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "users" ("userId") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "login_sessions" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"device" TEXT,
"ip" TEXT,
"userAgent" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "login_sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("userId") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ip_blocks" (
"id" TEXT NOT NULL PRIMARY KEY,
"ip" TEXT NOT NULL,
"reason" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ip_blocks_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "users" ("userId") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "nickname_changes" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"oldNickname" TEXT NOT NULL,
"newNickname" TEXT NOT NULL,
"changedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "nickname_changes_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("userId") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "password_reset_tokens" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"usedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "password_reset_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("userId") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "boards_slug_key" ON "boards"("slug");
-- CreateIndex
CREATE INDEX "boards_status_sortOrder_idx" ON "boards"("status", "sortOrder");
-- CreateIndex
CREATE INDEX "boards_type_requiresApproval_idx" ON "boards"("type", "requiresApproval");
-- CreateIndex
CREATE INDEX "board_moderators_userId_idx" ON "board_moderators"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "board_moderators_boardId_userId_key" ON "board_moderators"("boardId", "userId");
-- CreateIndex
CREATE INDEX "posts_boardId_status_createdAt_idx" ON "posts"("boardId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "posts_boardId_isPinned_pinnedOrder_idx" ON "posts"("boardId", "isPinned", "pinnedOrder");
-- CreateIndex
CREATE UNIQUE INDEX "users_nickname_key" ON "users"("nickname");
-- CreateIndex
CREATE UNIQUE INDEX "users_phone_key" ON "users"("phone");
-- CreateIndex
CREATE INDEX "users_status_idx" ON "users"("status");
-- CreateIndex
CREATE INDEX "users_createdAt_idx" ON "users"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");
-- CreateIndex
CREATE INDEX "user_roles_roleId_idx" ON "user_roles"("roleId");
-- CreateIndex
CREATE UNIQUE INDEX "user_roles_userId_roleId_key" ON "user_roles"("userId", "roleId");
-- CreateIndex
CREATE INDEX "comments_postId_createdAt_idx" ON "comments"("postId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "tags_name_key" ON "tags"("name");
-- CreateIndex
CREATE UNIQUE INDEX "tags_slug_key" ON "tags"("slug");
-- CreateIndex
CREATE INDEX "post_tags_tagId_idx" ON "post_tags"("tagId");
-- CreateIndex
CREATE UNIQUE INDEX "post_tags_postId_tagId_key" ON "post_tags"("postId", "tagId");
-- CreateIndex
CREATE INDEX "attachments_postId_idx" ON "attachments"("postId");
-- CreateIndex
CREATE INDEX "reactions_postId_type_idx" ON "reactions"("postId", "type");
-- CreateIndex
CREATE INDEX "reactions_userId_idx" ON "reactions"("userId");
-- CreateIndex
CREATE INDEX "reactions_clientHash_idx" ON "reactions"("clientHash");
-- CreateIndex
CREATE UNIQUE INDEX "reactions_postId_type_userId_clientHash_key" ON "reactions"("postId", "type", "userId", "clientHash");
-- CreateIndex
CREATE INDEX "post_view_logs_postId_createdAt_idx" ON "post_view_logs"("postId", "createdAt");
-- CreateIndex
CREATE INDEX "post_view_logs_userId_idx" ON "post_view_logs"("userId");
-- CreateIndex
CREATE INDEX "reports_targetType_idx" ON "reports"("targetType");
-- CreateIndex
CREATE INDEX "reports_postId_idx" ON "reports"("postId");
-- CreateIndex
CREATE INDEX "reports_commentId_idx" ON "reports"("commentId");
-- CreateIndex
CREATE INDEX "messages_senderId_createdAt_idx" ON "messages"("senderId", "createdAt");
-- CreateIndex
CREATE INDEX "messages_receiverId_createdAt_idx" ON "messages"("receiverId", "createdAt");
-- CreateIndex
CREATE INDEX "blocks_blockedId_idx" ON "blocks"("blockedId");
-- CreateIndex
CREATE UNIQUE INDEX "blocks_blockerId_blockedId_key" ON "blocks"("blockerId", "blockedId");
-- CreateIndex
CREATE INDEX "point_transactions_userId_createdAt_idx" ON "point_transactions"("userId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "level_thresholds_level_key" ON "level_thresholds"("level");
-- CreateIndex
CREATE UNIQUE INDEX "banned_keywords_pattern_key" ON "banned_keywords"("pattern");
-- CreateIndex
CREATE INDEX "banned_keywords_appliesTo_idx" ON "banned_keywords"("appliesTo");
-- CreateIndex
CREATE INDEX "sanctions_userId_type_idx" ON "sanctions"("userId", "type");
-- CreateIndex
CREATE INDEX "audit_logs_action_createdAt_idx" ON "audit_logs"("action", "createdAt");
-- CreateIndex
CREATE INDEX "admin_notifications_type_createdAt_idx" ON "admin_notifications"("type", "createdAt");
-- CreateIndex
CREATE INDEX "login_sessions_userId_lastSeenAt_idx" ON "login_sessions"("userId", "lastSeenAt");
-- CreateIndex
CREATE UNIQUE INDEX "ip_blocks_ip_active_key" ON "ip_blocks"("ip", "active");
-- CreateIndex
CREATE INDEX "nickname_changes_userId_changedAt_idx" ON "nickname_changes"("userId", "changedAt");
-- CreateIndex
CREATE UNIQUE INDEX "password_reset_tokens_token_key" ON "password_reset_tokens"("token");
-- CreateIndex
CREATE INDEX "password_reset_tokens_userId_expiresAt_idx" ON "password_reset_tokens"("userId", "expiresAt");

View File

@@ -15,8 +15,6 @@ datasource db {
url = env("DATABASE_URL")
}
// schema.prisma (MySQL)
// ==== Enums ====
enum BoardStatus {
active
@@ -24,11 +22,16 @@ enum BoardStatus {
archived
}
enum BoardType {
general
special
}
enum AccessLevel {
public // 비회원도 접근
member // 로그인 필요
moderator // 운영진 이상
admin // 관리자만
public // 비회원도 접근
member // 로그인 필요
moderator // 운영진 이상
admin // 관리자만
}
enum PostStatus {
@@ -37,6 +40,11 @@ enum PostStatus {
deleted
}
enum AttachmentType {
image
file
}
enum BoardModRole {
MODERATOR
MANAGER
@@ -54,114 +62,489 @@ enum AuthLevel {
ADMIN
}
enum ReactionType {
RECOMMEND
REPORT
}
enum SanctionType {
SUSPEND
BAN
DOWNGRADE
}
enum TargetType {
POST
COMMENT
USER
BOARD
SYSTEM
}
// ==== Models ====
// 게시판 정의(관리자 생성/정렬)
model Board {
id String @id @default(cuid())
name String
slug String @unique // URL용
description String?
sortOrder Int @default(0) // 원하는 순서
status BoardStatus @default(active)
isAdultOnly Boolean @default(false) // 성인 인증 필요 여부
id String @id @default(cuid())
name String
slug String @unique // URL용
description String?
sortOrder Int @default(0)
status BoardStatus @default(active)
type BoardType @default(general) // 일반/특수
requiresApproval Boolean @default(false) // 게시물 승인 필요 여부
allowAnonymousPost Boolean @default(false) // 익명 글 허용
allowSecretComment Boolean @default(false) // 비밀댓글 허용
isAdultOnly Boolean @default(false) // 성인 인증 필요 여부
requiredTags Json?
requiredFields Json?
readLevel AccessLevel @default(public) // 읽기 권한
writeLevel AccessLevel @default(member) // 글쓰기 권한
readLevel AccessLevel @default(public) // 읽기 권한
writeLevel AccessLevel @default(member) // 글쓰기 권한
posts Post[]
moderators BoardModerator[]
posts Post[]
moderators BoardModerator[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("boards")
@@index([status, sortOrder])
@@index([type, requiresApproval])
@@map("boards")
}
// 게시판 운영진 매핑
model BoardModerator {
id String @id @default(cuid())
boardId String
userId String
role BoardModRole @default(MODERATOR)
id String @id @default(cuid())
boardId String
userId String
role BoardModRole @default(MODERATOR)
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [userId], onDelete: Cascade)
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [userId], onDelete: Cascade)
createdAt DateTime @default(now())
@@map("board_moderators")
@@unique([boardId, userId]) // 한 게시판당 1회 매핑
@@index([userId])
@@map("board_moderators")
}
// 게시글(게시판 내 컨텐츠)
model Post {
id String @id @default(cuid())
boardId String
authorId String?
title String
content String
status PostStatus @default(published)
id String @id @default(cuid())
boardId String
authorId String?
title String
content String
status PostStatus @default(published)
isAnonymous Boolean @default(false)
isPinned Boolean @default(false)
pinnedOrder Int? // 상단 고정 정렬
isPinned Boolean @default(false)
pinnedOrder Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastActivityAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastActivityAt DateTime @default(now())
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
comments Comment[]
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
comments Comment[]
attachments Attachment[]
postTags PostTag[]
reactions Reaction[]
reports Report[]
stat PostStat?
viewLogs PostViewLog[]
@@map("posts")
@@index([boardId, status, createdAt])
@@index([boardId, isPinned, pinnedOrder])
@@map("posts")
}
// ---- 참고: 기존 User 모델 예시 ----
// 사용자
model User {
userId String @id @default(cuid())
nickname String @unique
passwordHash String?
name String
birth DateTime
phone String @unique
rank Int @default(0)
userId String @id @default(cuid())
nickname String @unique
passwordHash String?
name String
birth DateTime
phone String @unique
rank Int @default(0)
status UserStatus @default(active)
authLevel AuthLevel @default(USER)
status UserStatus @default(active)
authLevel AuthLevel @default(USER)
profileImage String?
lastLoginAt DateTime?
isAdultVerified Boolean @default(false)
loginFailCount Int @default(0)
isAdultVerified Boolean @default(false)
loginFailCount Int @default(0)
agreementTermsAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
boardModerations BoardModerator[]
comments Comment[]
posts Post[]
boardModerations BoardModerator[]
comments Comment[]
userRoles UserRole[]
reportsFiled Report[] @relation("ReportReporter")
reactions Reaction[] @relation("UserReactions")
messagesSent Message[] @relation("MessageSender")
messagesReceived Message[] @relation("MessageReceiver")
blocksInitiated Block[] @relation("Blocker")
blocksReceived Block[] @relation("Blocked")
pointTxns PointTransaction[]
sanctions Sanction[]
nicknameChanges NicknameChange[]
passwordResetTokens PasswordResetToken[] @relation("PasswordResetUser")
auditLogs AuditLog[] @relation("AuditActor")
loginSessions LoginSession[]
adminNotifications AdminNotification[] @relation("AdminNotificationCreator")
ipBlocksCreated IPBlock[] @relation("IPBlockCreator")
postViewLogs PostViewLog[] @relation("PostViewLogUser")
@@map("users")
@@index([status])
@@index([createdAt])
@@map("users")
}
// 역할 정의 (RBAC)
model Role {
roleId String @id @default(cuid())
name String @unique
description String?
userRoles UserRole[]
createdAt DateTime @default(now())
@@map("roles")
}
// 사용자-역할 매핑 (다대다)
model UserRole {
id String @id @default(cuid())
userId String
roleId String
user User @relation(fields: [userId], references: [userId], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [roleId], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, roleId])
@@index([roleId])
@@map("user_roles")
}
// 댓글
model Comment {
id String @id @default(cuid())
postId String
authorId String?
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
postId String
authorId String?
content String
isAnonymous Boolean @default(false)
isSecret Boolean @default(false)
secretPasswordHash String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
reports Report[]
@@map("comments")
@@index([postId, createdAt])
@@map("comments")
}
// 태그 및 매핑
model Tag {
tagId String @id @default(cuid())
name String @unique
slug String @unique
postTags PostTag[]
createdAt DateTime @default(now())
@@map("tags")
}
model PostTag {
id String @id @default(cuid())
postId String
tagId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [tagId], onDelete: Cascade)
@@unique([postId, tagId])
@@index([tagId])
@@map("post_tags")
}
// 첨부파일(이미지/파일)
model Attachment {
id String @id @default(cuid())
postId String
url String
type AttachmentType @default(image)
size Int?
width Int?
height Int?
sortOrder Int?
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([postId])
@@map("attachments")
}
// 반응(추천/신고)
model Reaction {
id String @id @default(cuid())
postId String
userId String?
clientHash String?
type ReactionType @default(RECOMMEND)
createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
user User? @relation("UserReactions", fields: [userId], references: [userId], onDelete: SetNull)
@@unique([postId, type, userId, clientHash])
@@index([postId, type])
@@index([userId])
@@index([clientHash])
@@map("reactions")
}
// 게시글 열람 로그(게시판별 열람 로그 요구)
model PostViewLog {
id String @id @default(cuid())
postId String
userId String?
ip String?
userAgent String?
createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
user User? @relation("PostViewLogUser", fields: [userId], references: [userId], onDelete: SetNull)
@@index([postId, createdAt])
@@index([userId])
@@map("post_view_logs")
}
// 게시글 통계 카운터
model PostStat {
postId String @id
views Int @default(0)
recommendCount Int @default(0)
reportCount Int @default(0)
commentsCount Int @default(0)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@map("post_stats")
}
// 신고(게시글/댓글 대상)
model Report {
id String @id @default(cuid())
reporterId String?
targetType TargetType
postId String?
commentId String?
reason String
status String @default("open")
createdAt DateTime @default(now())
reporter User? @relation("ReportReporter", fields: [reporterId], references: [userId], onDelete: SetNull)
post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
@@index([targetType])
@@index([postId])
@@index([commentId])
@@map("reports")
}
// 쪽지(DM)
model Message {
id String @id @default(cuid())
senderId String
receiverId String
body String
readAt DateTime?
createdAt DateTime @default(now())
sender User @relation("MessageSender", fields: [senderId], references: [userId], onDelete: Cascade)
receiver User @relation("MessageReceiver", fields: [receiverId], references: [userId], onDelete: Cascade)
@@index([senderId, createdAt])
@@index([receiverId, createdAt])
@@map("messages")
}
// 차단
model Block {
id String @id @default(cuid())
blockerId String
blockedId String
createdAt DateTime @default(now())
blocker User @relation("Blocker", fields: [blockerId], references: [userId], onDelete: Cascade)
blocked User @relation("Blocked", fields: [blockedId], references: [userId], onDelete: Cascade)
@@unique([blockerId, blockedId])
@@index([blockedId])
@@map("blocks")
}
// 포인트 트랜잭션
model PointTransaction {
id String @id @default(cuid())
userId String
amount Int // + 적립, - 차감
reason String
referenceType TargetType?
referenceId String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [userId], onDelete: Cascade)
@@index([userId, createdAt])
@@map("point_transactions")
}
// 레벨 임계치(선택)
model LevelThreshold {
id String @id @default(cuid())
level Int @unique
minPoints Int
@@map("level_thresholds")
}
// 금칙어 정책
model BannedKeyword {
id String @id @default(cuid())
pattern String @unique
appliesTo TargetType // POST/COMMENT/MESSAGE 등
severity Int @default(1)
active Boolean @default(true)
createdAt DateTime @default(now())
@@index([appliesTo])
@@map("banned_keywords")
}
// 제재 이력
model Sanction {
id String @id @default(cuid())
userId String
type SanctionType
reason String
startAt DateTime @default(now())
endAt DateTime?
createdBy String?
user User @relation(fields: [userId], references: [userId], onDelete: Cascade)
@@index([userId, type])
@@map("sanctions")
}
// 감사 로그
model AuditLog {
id String @id @default(cuid())
actorId String?
action String
targetType TargetType?
targetId String?
meta Json?
createdAt DateTime @default(now())
actor User? @relation("AuditActor", fields: [actorId], references: [userId], onDelete: SetNull)
@@index([action, createdAt])
@@map("audit_logs")
}
// 관리자 알림함(신고/포인트 이상/후기 등 이벤트)
model AdminNotification {
id String @id @default(cuid())
type String
message String
targetType TargetType?
targetId String?
createdBy String?
createdAt DateTime @default(now())
readAt DateTime?
createdByUser User? @relation("AdminNotificationCreator", fields: [createdBy], references: [userId], onDelete: SetNull)
@@index([type, createdAt])
@@map("admin_notifications")
}
// 로그인 기기/최근 접속 IP 기록
model LoginSession {
id String @id @default(cuid())
userId String
device String?
ip String?
userAgent String?
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
user User @relation(fields: [userId], references: [userId], onDelete: Cascade)
@@index([userId, lastSeenAt])
@@map("login_sessions")
}
// IP 차단 목록
model IPBlock {
id String @id @default(cuid())
ip String
reason String?
active Boolean @default(true)
createdBy String?
createdAt DateTime @default(now())
createdByUser User? @relation("IPBlockCreator", fields: [createdBy], references: [userId], onDelete: SetNull)
@@unique([ip, active])
@@map("ip_blocks")
}
// 닉네임 변경 이력
model NicknameChange {
id String @id @default(cuid())
userId String
oldNickname String
newNickname String
changedAt DateTime @default(now())
user User @relation(fields: [userId], references: [userId], onDelete: Cascade)
@@index([userId, changedAt])
@@map("nickname_changes")
}
// 인증 보조 - 비밀번호 재설정 토큰
model PasswordResetToken {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
user User @relation("PasswordResetUser", fields: [userId], references: [userId], onDelete: Cascade)
@@index([userId, expiresAt])
@@map("password_reset_tokens")
}

View File

@@ -2,53 +2,163 @@ const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function main() {
const user = await prisma.user.upsert({
where: { nickname: "tester" },
async function upsertRoles() {
const roles = [
{ name: "admin", description: "관리자" },
{ name: "editor", description: "운영진" },
{ name: "user", description: "일반 사용자" }
];
for (const r of roles) {
await prisma.role.upsert({
where: { name: r.name },
update: { description: r.description },
create: r,
});
}
}
async function upsertAdmin() {
const admin = await prisma.user.upsert({
where: { nickname: "admin" },
update: {},
create: {
nickname: "tester",
name: "Tester",
nickname: "admin",
name: "Administrator",
birth: new Date("1990-01-01"),
phone: "010-0000-0000",
agreementTermsAt: new Date()
}
phone: "010-0000-0001",
agreementTermsAt: new Date(),
authLevel: "ADMIN",
},
});
const board = await prisma.board.upsert({
where: { slug: "general" },
update: {},
create: {
name: "General",
slug: "general",
description: "일반 게시판",
sortOrder: 0
}
});
const adminRole = await prisma.role.findUnique({ where: { name: "admin" } });
if (adminRole) {
await prisma.userRole.upsert({
where: {
userId_roleId: { userId: admin.userId, roleId: adminRole.roleId },
},
update: {},
create: { userId: admin.userId, roleId: adminRole.roleId },
});
}
return admin;
}
async function upsertBoards(admin) {
const boards = [
// 일반
{ name: "공지사항", slug: "notice", description: "공지", type: "general", sortOrder: 1 },
{ name: "가입인사", slug: "greetings", description: "가입인사", type: "general", sortOrder: 2 },
{ name: "버그건의", slug: "bug-report", description: "버그/건의", type: "general", sortOrder: 3 },
{ name: "이벤트", slug: "event", description: "이벤트", type: "general", sortOrder: 4 },
{ name: "자유게시판", slug: "free", description: "자유", type: "general", sortOrder: 5 },
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", type: "general", sortOrder: 6 },
{ name: "마사지꿀팁", slug: "tips", description: "팁", type: "general", sortOrder: 7 },
{ name: "익명게시판", slug: "anonymous", description: "익명", type: "general", sortOrder: 8, allowAnonymousPost: true },
{ name: "관리사찾아요", slug: "find-therapist", description: "구인/구직", type: "general", sortOrder: 9 },
{ name: "청와대", slug: "blue-house", description: "레벨 제한", type: "general", sortOrder: 10, readLevel: "member" },
{ name: "방문후기", slug: "reviews", description: "운영자 승인 후 공개", type: "general", sortOrder: 11, requiresApproval: true },
// 특수
{ name: "출석부", slug: "attendance", description: "데일리 체크인", type: "special", sortOrder: 12 },
{ name: "주변 제휴업체", slug: "nearby-partners", description: "위치 기반", type: "special", sortOrder: 13 },
{ name: "회원랭킹", slug: "ranking", description: "랭킹", type: "special", sortOrder: 14 },
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", type: "special", sortOrder: 15 },
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", type: "special", sortOrder: 16 },
];
const created = [];
for (const b of boards) {
const board = await prisma.board.upsert({
where: { slug: b.slug },
update: {
description: b.description,
sortOrder: b.sortOrder,
type: b.type,
requiresApproval: !!b.requiresApproval,
allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined,
},
create: {
name: b.name,
slug: b.slug,
description: b.description,
sortOrder: b.sortOrder,
type: b.type,
requiresApproval: !!b.requiresApproval,
allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined,
},
});
created.push(board);
// 공지/운영 보드는 관리자 모더레이터 지정
if (["notice", "bug-report"].includes(board.slug)) {
await prisma.boardModerator.upsert({
where: {
boardId_userId: { boardId: board.id, userId: admin.userId },
},
update: {},
create: { boardId: board.id, userId: admin.userId, role: "MANAGER" },
});
}
}
return created;
}
async function seedPolicies() {
// 금칙어 예시
const banned = [
{ pattern: "광고", appliesTo: "POST", severity: 1 },
{ pattern: "욕설", appliesTo: "COMMENT", severity: 2 },
{ pattern: "스팸", appliesTo: "POST", severity: 2 },
];
for (const k of banned) {
await prisma.bannedKeyword.upsert({
where: { pattern: k.pattern },
update: { appliesTo: k.appliesTo, severity: k.severity, active: true },
create: k,
});
}
// 레벨 임계 예시
const levels = [
{ level: 1, minPoints: 0 },
{ level: 2, minPoints: 100 },
{ level: 3, minPoints: 300 },
];
for (const l of levels) {
await prisma.levelThreshold.upsert({
where: { level: l.level },
update: { minPoints: l.minPoints },
create: l,
});
}
}
async function main() {
await upsertRoles();
const admin = await upsertAdmin();
const boards = await upsertBoards(admin);
// 샘플 글 하나
const free = boards.find((b) => b.slug === "free") || boards[0];
const post = await prisma.post.create({
data: {
boardId: board.id,
authorId: user.userId,
boardId: free.id,
authorId: admin.userId,
title: "첫 글",
content: "Hello SQLite + Prisma",
isPinned: false,
status: "published"
}
content: "메시지 앱 초기 설정 완료",
status: "published",
},
});
await prisma.boardModerator.upsert({
where: { boardId_userId: { boardId: board.id, userId: user.userId } },
update: {},
create: { boardId: board.id, userId: user.userId, role: "MODERATOR" }
});
await prisma.comment.createMany({
data: [
{ postId: post.id, authorId: user.userId, content: "첫 댓글" },
{ postId: post.id, authorId: user.userId, content: "두번째 댓글" }
]
{ postId: post.id, authorId: admin.userId, content: "환영합니다!" },
{ postId: post.id, authorId: admin.userId, content: "댓글 테스트" },
],
});
await seedPolicies();
}
main()

View File

@@ -285,11 +285,11 @@ Hero/공지 배너 영역
-- 해야할 일(작업 단위)
[백엔드/API]
1. Prisma 스키마 최종 확정(모델/관계/index/unique)
2. 마이그레이션 생성 및 적용(prisma migrate dev)
3. Prisma Client 재생성 및 dev DB 초기화
4. 시드 스크립트 분리(역할/기본 관리자/샘플 게시판)
5. 서비스/리포지토리 계층 뼈대 생성(트랜잭션 유틸 포함)
1. Prisma 스키마 최종 확정(모델/관계/index/unique) o
2. 마이그레이션 생성 및 적용(prisma migrate dev) o
3. Prisma Client 재생성 및 dev DB 초기화 o
4. 시드 스크립트 분리(역할/기본 관리자/샘플 게시판) o
5. 서비스/리포지토리 계층 뼈대 생성(트랜잭션 유틸 포함) o
[권한/역할(RBAC)]
1. 역할·권한 시드(admin/editor/user) 추가

1
public/erd.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 601 KiB

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function GET() {
const boards = await prisma.board.findMany({
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: {
id: true,
name: true,
slug: true,
description: true,
type: true,
requiresApproval: true,
allowAnonymousPost: true,
isAdultOnly: true,
},
});
return NextResponse.json({ boards });
}

View File

@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
const createCommentSchema = z.object({
postId: z.string().min(1),
authorId: z.string().optional(),
content: z.string().min(1),
isAnonymous: z.boolean().optional(),
isSecret: z.boolean().optional(),
});
export async function POST(req: Request) {
const body = await req.json();
const parsed = createCommentSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { postId, authorId, content, isAnonymous, isSecret } = parsed.data;
const comment = await prisma.comment.create({
data: {
postId,
authorId: authorId ?? null,
content,
isAnonymous: !!isAnonymous,
isSecret: !!isSecret,
},
});
return NextResponse.json({ comment }, { status: 201 });
}

View File

@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
const createPostSchema = z.object({
boardId: z.string().min(1),
authorId: z.string().optional(),
title: z.string().min(1),
content: z.string().min(1),
isAnonymous: z.boolean().optional(),
});
export async function POST(req: Request) {
const body = await req.json();
const parsed = createPostSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { boardId, authorId, title, content, isAnonymous } = parsed.data;
const post = await prisma.post.create({
data: { boardId, authorId: authorId ?? null, title, content, isAnonymous: !!isAnonymous },
});
return NextResponse.json({ post }, { status: 201 });
}