From 296df7b5827a13796b018fd300d98e83961b856f Mon Sep 17 00:00:00 2001 From: koreacomp5 Date: Thu, 9 Oct 2025 11:23:27 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 5 +- package.json | 1 + .../20251009021103_init_schema/migration.sql | 466 ++++++++++++++++ prisma/schema.prisma | 521 +++++++++++++++--- prisma/seed.js | 178 ++++-- projectmemo.txt | 10 +- public/erd.svg | 1 + src/app/api/boards/route.ts | 21 + src/app/api/comments/route.ts | 32 ++ src/app/api/posts/route.ts | 26 + 10 files changed, 1150 insertions(+), 111 deletions(-) create mode 100644 prisma/migrations/20251009021103_init_schema/migration.sql create mode 100644 public/erd.svg create mode 100644 src/app/api/boards/route.ts create mode 100644 src/app/api/comments/route.ts create mode 100644 src/app/api/posts/route.ts diff --git a/package-lock.json b/package-lock.json index f8b8e25..1279d5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/package.json b/package.json index e431964..3b689f1 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/prisma/migrations/20251009021103_init_schema/migration.sql b/prisma/migrations/20251009021103_init_schema/migration.sql new file mode 100644 index 0000000..cc2564d --- /dev/null +++ b/prisma/migrations/20251009021103_init_schema/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 160ee2f..2061e96 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/prisma/seed.js b/prisma/seed.js index 4806e34..d4e05eb 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -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() diff --git a/projectmemo.txt b/projectmemo.txt index d67eb3a..e074f33 100644 --- a/projectmemo.txt +++ b/projectmemo.txt @@ -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) 추가 diff --git a/public/erd.svg b/public/erd.svg new file mode 100644 index 0000000..30f01db --- /dev/null +++ b/public/erd.svg @@ -0,0 +1 @@ +

enum:status

enum:type

enum:readLevel

enum:writeLevel

enum:role

board

user

enum:status

board

author

enum:status

enum:authLevel

user

role

post

author

post

tag

enum:type

post

enum:type

post

user

post

user

post

enum:targetType

reporter

post

comment

sender

receiver

blocker

blocked

enum:referenceType

user

enum:appliesTo

enum:type

user

enum:targetType

actor

enum:targetType

createdByUser

user

createdByUser

user

user

BoardStatus

active

active

hidden

hidden

archived

archived

BoardType

general

general

special

special

AccessLevel

public

public

member

member

moderator

moderator

admin

admin

PostStatus

published

published

hidden

hidden

deleted

deleted

AttachmentType

image

image

file

file

BoardModRole

MODERATOR

MODERATOR

MANAGER

MANAGER

UserStatus

active

active

suspended

suspended

withdrawn

withdrawn

AuthLevel

USER

USER

MOD

MOD

ADMIN

ADMIN

ReactionType

RECOMMEND

RECOMMEND

REPORT

REPORT

SanctionType

SUSPEND

SUSPEND

BAN

BAN

DOWNGRADE

DOWNGRADE

TargetType

POST

POST

COMMENT

COMMENT

USER

USER

BOARD

BOARD

SYSTEM

SYSTEM

boards

String

id

🗝️

String

name

String

slug

String

description

Int

sortOrder

BoardStatus

status

BoardType

type

Boolean

requiresApproval

Boolean

allowAnonymousPost

Boolean

allowSecretComment

Boolean

isAdultOnly

Json

requiredTags

Json

requiredFields

AccessLevel

readLevel

AccessLevel

writeLevel

DateTime

createdAt

DateTime

updatedAt

board_moderators

String

id

🗝️

BoardModRole

role

DateTime

createdAt

posts

String

id

🗝️

String

title

String

content

PostStatus

status

Boolean

isAnonymous

Boolean

isPinned

Int

pinnedOrder

DateTime

createdAt

DateTime

updatedAt

DateTime

lastActivityAt

users

String

userId

🗝️

String

nickname

String

passwordHash

String

name

DateTime

birth

String

phone

Int

rank

UserStatus

status

AuthLevel

authLevel

String

profileImage

DateTime

lastLoginAt

Boolean

isAdultVerified

Int

loginFailCount

DateTime

agreementTermsAt

DateTime

createdAt

DateTime

updatedAt

roles

String

roleId

🗝️

String

name

String

description

DateTime

createdAt

user_roles

String

id

🗝️

DateTime

createdAt

comments

String

id

🗝️

String

content

Boolean

isAnonymous

Boolean

isSecret

String

secretPasswordHash

DateTime

createdAt

DateTime

updatedAt

tags

String

tagId

🗝️

String

name

String

slug

DateTime

createdAt

post_tags

String

id

🗝️

attachments

String

id

🗝️

String

url

AttachmentType

type

Int

size

Int

width

Int

height

Int

sortOrder

DateTime

createdAt

reactions

String

id

🗝️

String

clientHash

ReactionType

type

DateTime

createdAt

post_view_logs

String

id

🗝️

String

ip

String

userAgent

DateTime

createdAt

post_stats

Int

views

Int

recommendCount

Int

reportCount

Int

commentsCount

reports

String

id

🗝️

TargetType

targetType

String

reason

String

status

DateTime

createdAt

messages

String

id

🗝️

String

body

DateTime

readAt

DateTime

createdAt

blocks

String

id

🗝️

DateTime

createdAt

point_transactions

String

id

🗝️

Int

amount

String

reason

TargetType

referenceType

String

referenceId

DateTime

createdAt

level_thresholds

String

id

🗝️

Int

level

Int

minPoints

banned_keywords

String

id

🗝️

String

pattern

TargetType

appliesTo

Int

severity

Boolean

active

DateTime

createdAt

sanctions

String

id

🗝️

SanctionType

type

String

reason

DateTime

startAt

DateTime

endAt

String

createdBy

audit_logs

String

id

🗝️

String

action

TargetType

targetType

String

targetId

Json

meta

DateTime

createdAt

admin_notifications

String

id

🗝️

String

type

String

message

TargetType

targetType

String

targetId

DateTime

createdAt

DateTime

readAt

login_sessions

String

id

🗝️

String

device

String

ip

String

userAgent

DateTime

createdAt

DateTime

lastSeenAt

ip_blocks

String

id

🗝️

String

ip

String

reason

Boolean

active

DateTime

createdAt

nickname_changes

String

id

🗝️

String

oldNickname

String

newNickname

DateTime

changedAt

password_reset_tokens

String

id

🗝️

String

token

DateTime

expiresAt

DateTime

usedAt

DateTime

createdAt

\ No newline at end of file diff --git a/src/app/api/boards/route.ts b/src/app/api/boards/route.ts new file mode 100644 index 0000000..400b894 --- /dev/null +++ b/src/app/api/boards/route.ts @@ -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 }); +} + + diff --git a/src/app/api/comments/route.ts b/src/app/api/comments/route.ts new file mode 100644 index 0000000..c8f117d --- /dev/null +++ b/src/app/api/comments/route.ts @@ -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 }); +} + + diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts new file mode 100644 index 0000000..f76d41a --- /dev/null +++ b/src/app/api/posts/route.ts @@ -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 }); +} + +