스키마 작업완료

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

View File

@@ -17,6 +17,7 @@
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.17.0", "@prisma/client": "^6.17.0",
"zod": "^3.23.8",
"next": "15.5.4", "next": "15.5.4",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "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") url = env("DATABASE_URL")
} }
// schema.prisma (MySQL)
// ==== Enums ==== // ==== Enums ====
enum BoardStatus { enum BoardStatus {
active active
@@ -24,6 +22,11 @@ enum BoardStatus {
archived archived
} }
enum BoardType {
general
special
}
enum AccessLevel { enum AccessLevel {
public // 비회원도 접근 public // 비회원도 접근
member // 로그인 필요 member // 로그인 필요
@@ -37,6 +40,11 @@ enum PostStatus {
deleted deleted
} }
enum AttachmentType {
image
file
}
enum BoardModRole { enum BoardModRole {
MODERATOR MODERATOR
MANAGER MANAGER
@@ -54,6 +62,25 @@ enum AuthLevel {
ADMIN ADMIN
} }
enum ReactionType {
RECOMMEND
REPORT
}
enum SanctionType {
SUSPEND
BAN
DOWNGRADE
}
enum TargetType {
POST
COMMENT
USER
BOARD
SYSTEM
}
// ==== Models ==== // ==== Models ====
// 게시판 정의(관리자 생성/정렬) // 게시판 정의(관리자 생성/정렬)
@@ -62,9 +89,15 @@ model Board {
name String name String
slug String @unique // URL용 slug String @unique // URL용
description String? description String?
sortOrder Int @default(0) // 원하는 순서 sortOrder Int @default(0)
status BoardStatus @default(active) status BoardStatus @default(active)
type BoardType @default(general) // 일반/특수
requiresApproval Boolean @default(false) // 게시물 승인 필요 여부
allowAnonymousPost Boolean @default(false) // 익명 글 허용
allowSecretComment Boolean @default(false) // 비밀댓글 허용
isAdultOnly Boolean @default(false) // 성인 인증 필요 여부 isAdultOnly Boolean @default(false) // 성인 인증 필요 여부
requiredTags Json?
requiredFields Json?
readLevel AccessLevel @default(public) // 읽기 권한 readLevel AccessLevel @default(public) // 읽기 권한
writeLevel AccessLevel @default(member) // 글쓰기 권한 writeLevel AccessLevel @default(member) // 글쓰기 권한
@@ -75,8 +108,9 @@ model Board {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@map("boards")
@@index([status, sortOrder]) @@index([status, sortOrder])
@@index([type, requiresApproval])
@@map("boards")
} }
// 게시판 운영진 매핑 // 게시판 운영진 매핑
@@ -91,9 +125,9 @@ model BoardModerator {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@map("board_moderators")
@@unique([boardId, userId]) // 한 게시판당 1회 매핑 @@unique([boardId, userId]) // 한 게시판당 1회 매핑
@@index([userId]) @@index([userId])
@@map("board_moderators")
} }
// 게시글(게시판 내 컨텐츠) // 게시글(게시판 내 컨텐츠)
@@ -104,9 +138,10 @@ model Post {
title String title String
content String content String
status PostStatus @default(published) status PostStatus @default(published)
isAnonymous Boolean @default(false)
isPinned Boolean @default(false) isPinned Boolean @default(false)
pinnedOrder Int? // 상단 고정 정렬 pinnedOrder Int?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -115,13 +150,19 @@ model Post {
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull) author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
comments Comment[] comments Comment[]
attachments Attachment[]
postTags PostTag[]
reactions Reaction[]
reports Report[]
stat PostStat?
viewLogs PostViewLog[]
@@map("posts")
@@index([boardId, status, createdAt]) @@index([boardId, status, createdAt])
@@index([boardId, isPinned, pinnedOrder]) @@index([boardId, isPinned, pinnedOrder])
@@map("posts")
} }
// ---- 참고: 기존 User 모델 예시 ---- // 사용자
model User { model User {
userId String @id @default(cuid()) userId String @id @default(cuid())
nickname String @unique nickname String @unique
@@ -145,23 +186,365 @@ model User {
posts Post[] posts Post[]
boardModerations BoardModerator[] boardModerations BoardModerator[]
comments Comment[] 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([status])
@@index([createdAt]) @@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 { model Comment {
id String @id @default(cuid()) id String @id @default(cuid())
postId String postId String
authorId String? authorId String?
content String content String
isAnonymous Boolean @default(false)
isSecret Boolean @default(false)
secretPasswordHash String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
post Post @relation(fields: [postId], references: [id], onDelete: Cascade) post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull) author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
reports Report[]
@@map("comments")
@@index([postId, createdAt]) @@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(); const prisma = new PrismaClient();
async function main() { async function upsertRoles() {
const user = await prisma.user.upsert({ const roles = [
where: { nickname: "tester" }, { 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: {}, update: {},
create: { create: {
nickname: "tester", nickname: "admin",
name: "Tester", name: "Administrator",
birth: new Date("1990-01-01"), birth: new Date("1990-01-01"),
phone: "010-0000-0000", phone: "010-0000-0001",
agreementTermsAt: new Date() agreementTermsAt: new Date(),
} authLevel: "ADMIN",
},
}); });
const board = await prisma.board.upsert({ const adminRole = await prisma.role.findUnique({ where: { name: "admin" } });
where: { slug: "general" }, if (adminRole) {
await prisma.userRole.upsert({
where: {
userId_roleId: { userId: admin.userId, roleId: adminRole.roleId },
},
update: {}, update: {},
create: { create: { userId: admin.userId, roleId: adminRole.roleId },
name: "General",
slug: "general",
description: "일반 게시판",
sortOrder: 0
}
}); });
}
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({ const post = await prisma.post.create({
data: { data: {
boardId: board.id, boardId: free.id,
authorId: user.userId, authorId: admin.userId,
title: "첫 글", title: "첫 글",
content: "Hello SQLite + Prisma", content: "메시지 앱 초기 설정 완료",
isPinned: false, status: "published",
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({ await prisma.comment.createMany({
data: [ data: [
{ postId: post.id, authorId: user.userId, content: "첫 댓글" }, { postId: post.id, authorId: admin.userId, content: "환영합니다!" },
{ postId: post.id, authorId: user.userId, content: "두번째 댓글" } { postId: post.id, authorId: admin.userId, content: "댓글 테스트" },
] ],
}); });
await seedPolicies();
} }
main() main()

View File

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