Compare commits

...

62 Commits

Author SHA1 Message Date
koreacomp5
afc714022f 인기글 추가
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-28 06:12:58 +09:00
koreacomp5
c85450ce37 board notfound
All checks were successful
deploy-on-main / deploy (push) Successful in 37s
2025-11-28 05:29:34 +09:00
koreacomp5
c5bc8f5b49 check
All checks were successful
deploy-on-main / deploy (push) Successful in 26s
2025-11-10 21:58:36 +09:00
koreacomp5
14c80baeec ㄹㄹ
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-10 13:44:05 +09:00
koreacomp5
5287611bf7 test 제거
Some checks failed
deploy-on-main / deploy (push) Failing after 21s
2025-11-10 13:33:29 +09:00
koreacomp5
cb2d1f34d3 admin 권한 세팅
Some checks failed
deploy-on-main / deploy (push) Failing after 21s
2025-11-10 11:26:00 +09:00
koreacomp5
97c8e1c9fb build check
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-10 01:58:06 +09:00
koreacomp5
5485da4029 출석관련
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-10 01:56:44 +09:00
koreacomp5
b579b32138 컴잇
Some checks failed
deploy-on-main / deploy (push) Failing after 23s
2025-11-10 01:39:44 +09:00
koreacomp5
4337a8f69a fix
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-10 00:04:17 +09:00
koreacomp5
a007ac11ce test
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-09 22:05:22 +09:00
koreacomp5
34e831f738 cc
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-09 20:02:18 +09:00
koreacomp5
cfbb3d50ee 수정
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-09 19:53:42 +09:00
koreacomp5
1c2222da67 적용적용
Some checks failed
deploy-on-main / deploy (push) Failing after 23s
2025-11-08 01:21:44 +09:00
koreacomp5
bb71b892ca 중간
Some checks failed
deploy-on-main / deploy (push) Failing after 21s
2025-11-07 23:41:52 +09:00
koreacomp5
ab81a3da3d ??
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-07 21:36:34 +09:00
mota
5f72e6ce7c Merge branch 'imsi'
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-06 11:22:10 +09:00
mota
1ec2df27b0 imsi commit 2025-11-06 11:18:55 +09:00
koreacomp5
1fb859fdf9 메인화면 게시글 뷰 컨테이너 라디우스
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-06 00:04:40 +09:00
koreacomp5
e91085b4ed 메인 게시판뷰 디자인 디테일 2025-11-06 00:02:13 +09:00
koreacomp5
2d722a89c7 시드 또수정
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-05 23:50:01 +09:00
koreacomp5
c348fb55fa seed update
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-05 23:47:44 +09:00
koreacomp5
c7d5679788 사이드바 수정
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-05 23:45:13 +09:00
koreacomp5
a646dfd09f 메인화면 모바일 등등
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-05 23:38:35 +09:00
koreacomp5
4d1e7343c3 Merge branch 'homework'
All checks were successful
deploy-on-main / deploy (push) Successful in 35s
2025-11-05 23:05:38 +09:00
koreacomp5
808fe5fc68 header 2025-11-05 23:03:53 +09:00
koreacomp5
7d9c241d17 메인페이지 더수정 homework 2025-11-05 22:49:25 +09:00
koreacomp5
5b8749d11f 메인스타일, 호버 등 2025-11-05 22:27:29 +09:00
koreacomp5
e518c988b2 메인페이지 게시판뷰 바로가기 버튼 2025-11-05 21:17:14 +09:00
koreacomp5
12044587c9 homework1 2025-11-05 21:07:49 +09:00
ecf2dab35c last check
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-04 15:34:58 +00:00
1016d8717c deploytest002
All checks were successful
deploy-on-main / deploy (push) Successful in 40s
2025-11-04 15:33:52 +00:00
e7dd2cca00 deploytest 001
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:31:31 +00:00
34f9f0cb32 deploy.yml
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:25:23 +00:00
30c66158d5 123
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:22:52 +00:00
69c54fad1d deploytest2
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:20:26 +00:00
c20599164e deploy test
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:17:37 +00:00
664c72fa41 runnerfile commit
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:12:04 +00:00
486a18451b new address check2
Some checks failed
deploy-on-main / deploy (push) Failing after 8s
2025-11-04 14:48:49 +00:00
koreacomp5
9bfbafcec1 new address check
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 23:48:13 +09:00
mota
2c9898be90 test
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 17:28:17 +09:00
34b2739405 deploy fix
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 08:27:47 +00:00
4aaa542ded build error fix
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 08:20:03 +00:00
1049d029b4 deploysetting
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 08:01:05 +00:00
koreacomp5
f4e46c39fb appheader 2025-11-03 10:22:44 +09:00
mota
fadd402e63 123 2025-11-02 15:13:03 +09:00
mota
b10d41532b 123 2025-11-02 13:32:19 +09:00
mota
2047e044d5 Merge branch 'subwork' 2025-11-02 12:08:00 +09:00
mota
2c86f2d515 메인페이지 2025-11-02 12:07:11 +09:00
mota
870da3838a 네브바 2025-11-02 12:06:55 +09:00
mota
11726858cc Merge branch 'subwork' 2025-11-02 11:35:54 +09:00
mota
1ced95f218 main 2025-11-02 11:34:47 +09:00
mota
9e02aa3a88 글쓰기 2025-11-02 11:33:44 +09:00
mota
9ff08d3e58 dd 2025-11-02 11:01:18 +09:00
koreacomp5
e0aacab1d1 Merge branch 'mainwork' 2025-11-02 07:02:27 +09:00
koreacomp5
58af463585 ㅇㅇ 2025-11-02 07:01:42 +09:00
koreacomp5
d54ad82095 Merge branch 'subwork' into mainwork 2025-11-02 04:59:21 +09:00
koreacomp5
9c28d50890 main 2025-11-02 04:59:09 +09:00
koreacomp5
0bf270d884 sub 2025-11-02 04:59:04 +09:00
koreacomp5
c6e60cd34d Merge branch 'mainwork' into subwork 2025-11-02 04:40:04 +09:00
koreacomp5
c7f7492b9e Merge branch 'subwork' into mainwork 2025-11-02 04:39:42 +09:00
koreacomp5
4d310346c1 main 2025-11-02 04:39:28 +09:00
163 changed files with 6871 additions and 629 deletions

View File

@@ -1,17 +1,18 @@
배너 디테일
카드 디테일
메인 디테일 프리뷰, 글, 스페셜_랭크
기본 리스트 , 글이 없습니다.
글쓰기
글뷰, 댓글 +리스트
x 메인 게시판 일반
메인 게시판 프리뷰
x 메인 게시판 스페셜랭크
기본 리스트
스페셜_랭크 스페셜_랭크
스페셜_출석 스페셜_출석
스페셜_제휴업체 스페셜_제휴업체
스페셜_제휴업체지도 스페셜_제휴업체지도
게시글 뷰 + 댓글
로그인관련 로그인관련
회원가입 페이지
회원쪽지 회원쪽지
링크로들어오면 보이고 거기서 페이지이동하면안보이게 링크로들어오면 보이고 거기서 페이지 이동하면 안보이게

37
.cursor/.prompt/new.md Normal file
View File

@@ -0,0 +1,37 @@
7.게시판 열 구분선 잘보이게 요청 / 모바일 최적화 요청 및 구분선,높이 조절 요청
8.글삭제기능 어드민추가
9-1.시크로드 참고 - 제휴업체 리스트, 지역별 / 제휴업체 정보 생성 요청 (제휴업체 프로필,출근부)
9-2.제휴업체 및 프로필 등록은, 권한을부여한 제휴업체가 직접 등록하도록 해야합니다
9-3.제휴업체 등록 주인이하면안됨, 시크로드 - 제휴문의 - 제휴문의글쓰기 참고요청
제휴문의 게시판 ( 관리자 승인후) → 이동
제휴업소 리스트 게시판 (노출됨)"
10.배너관리 이미지 사이즈 규격 및 제휴업체 등록시 이미지 사이즈 규격
11,게시판, 주간인기글 , 일간인기글 추가 요청
12.https://search.google.com/search-console/welcome 등록요청
13.메인페이지 제휴업체 배너 이미지 규격작게 수정요청, 한번에 4개이상 정도 보이는 정도
14.메인페이지 제휴업체 클릭시 제휴업체 카테고리 하이퍼연결 추가 요청
15.레벨별 아이콘 페이지 / https://seekrod.co.kr/bbs/page.php?hid=point 참고
16. 게시글 상단고정 기능
17. 메인화면 큰 카테고리 빼기
18. google SEO: header, meta, description 동적설정
2. 회색 배경 영역(비밀글, 글자수)이 글 작성 영역 안에 있어야 함
로그인 프로세스 수정: 로그인시 로그인 팝업 모달
로그인 로그오프 시, 메인 프로필 디자인 누락되서 추가(지금상태로 가도 무상관)
1. 게시글에서 목록돌아가는 버튼 디자인과 다름
2. 게시글에서 제목, 내용, 댓글 디자인 다름
3. 게시글에서 최하단에 게시글리스트 부분 디자인 다
4. 게시글 리스트에서 리스트버튼 하단 divider 색상이 너무 연함
5. 표시개수 필요없을 거 같은데, 그냥 고정해버리죠?
9. 게시글 이미지 배치사이즈
10. 외부접속 가입없어도 가능
11. 글뷰에서 게시글 리스트로
12. 게시글 리스트 디자인
13. 포인트 규칙
14. 게시판권한 확인
15.메인뷰 3열 사이즈 변경
16. 로그인 안됐을때 카드 휑함
17.이미지 사이즈 미리불러오기

View File

@@ -0,0 +1,36 @@
name: deploy-on-main
on:
push:
branches: [ "main" ]
jobs:
deploy:
runs-on: [ self-hosted, linux_amd64 ]
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Node / PM2 / Prisma 확인
run: |
node -v
npm -v
pm2 -v || true
npx prisma --version || true
- name: 배포
env:
APP_DIR: /root/msgapp
run: |
set -e
cd "$APP_DIR"
# 최신 main 코드 반영
git fetch origin main
git reset --hard origin/main
npm ci
npm run dbforce
npm run build
pm2 reload ecosystem.config.js --env production || (pm2 start ecosystem.config.js --env production && pm2 save)
pm2 list

1
.gitignore vendored
View File

@@ -46,3 +46,4 @@ next-env.d.ts
/prisma/prisma/dev.db /prisma/prisma/dev.db
*.ignore *.ignore
/logs

13
.runner Normal file
View File

@@ -0,0 +1,13 @@
{
"WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.",
"id": 2,
"uuid": "7dc76cf1-fcf2-48ba-a97a-73fb5d661049",
"name": "msgapp",
"token": "f4391f32ea51657c4366698db1b8333b5d5e1b83",
"address": "https://www.plubu.com/",
"labels": [
"linux_amd64:host",
"self-hosted:host"
],
"ephemeral": false
}

6
deploytest.txt Normal file
View File

@@ -0,0 +1,6 @@
test
new address check
new address check2
deploy test2dfdf213
deploy test001
deploy test002

18
ecosystem.config.js Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
apps: [
{
name: 'assm',
cwd: '/root/msgapp',
script: 'npm',
args: 'start',
interpreter: 'none',
env: {
NODE_ENV: 'production',
},
autorestart: true,
watch: false,
},
],
};

3
middleware.ts Normal file
View File

@@ -0,0 +1,3 @@
export { middleware, config } from "./src/middleware";

View File

@@ -5,7 +5,7 @@
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build --turbopack", "build": "next build --turbopack",
"start": "next start", "start": "next start --port 3100",
"lint": "biome check", "lint": "biome check",
"format": "biome format --write", "format": "biome format --write",
"migrate": "prisma migrate dev", "migrate": "prisma migrate dev",

View File

@@ -0,0 +1,43 @@
/*
Warnings:
- You are about to drop the column `requiresApproval` on the `boards` table. All the data in the column will be lost.
- You are about to drop the column `type` on the `boards` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_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',
"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',
"categoryId" TEXT,
"mainPageViewTypeId" TEXT,
"listViewTypeId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "boards_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "board_categories" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "boards_mainPageViewTypeId_fkey" FOREIGN KEY ("mainPageViewTypeId") REFERENCES "board_view_types" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "boards_listViewTypeId_fkey" FOREIGN KEY ("listViewTypeId") REFERENCES "board_view_types" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_boards" ("allowAnonymousPost", "allowSecretComment", "categoryId", "createdAt", "description", "id", "isAdultOnly", "listViewTypeId", "mainPageViewTypeId", "name", "readLevel", "requiredFields", "requiredTags", "slug", "sortOrder", "status", "updatedAt", "writeLevel") SELECT "allowAnonymousPost", "allowSecretComment", "categoryId", "createdAt", "description", "id", "isAdultOnly", "listViewTypeId", "mainPageViewTypeId", "name", "readLevel", "requiredFields", "requiredTags", "slug", "sortOrder", "status", "updatedAt", "writeLevel" FROM "boards";
DROP TABLE "boards";
ALTER TABLE "new_boards" RENAME TO "boards";
CREATE UNIQUE INDEX "boards_slug_key" ON "boards"("slug");
CREATE INDEX "boards_status_sortOrder_idx" ON "boards"("status", "sortOrder");
CREATE INDEX "boards_categoryId_idx" ON "boards"("categoryId");
CREATE INDEX "boards_mainPageViewTypeId_idx" ON "boards"("mainPageViewTypeId");
CREATE INDEX "boards_listViewTypeId_idx" ON "boards"("listViewTypeId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,26 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_comments" (
"id" TEXT NOT NULL PRIMARY KEY,
"postId" TEXT NOT NULL,
"parentId" TEXT,
"depth" INTEGER NOT NULL DEFAULT 0,
"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_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "comments" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "comments_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users" ("userId") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_comments" ("authorId", "content", "createdAt", "id", "isAnonymous", "isSecret", "postId", "secretPasswordHash", "updatedAt") SELECT "authorId", "content", "createdAt", "id", "isAnonymous", "isSecret", "postId", "secretPasswordHash", "updatedAt" FROM "comments";
DROP TABLE "comments";
ALTER TABLE "new_comments" RENAME TO "comments";
CREATE INDEX "comments_postId_createdAt_idx" ON "comments"("postId", "createdAt");
CREATE INDEX "comments_parentId_idx" ON "comments"("parentId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -124,7 +124,6 @@ model Board {
description String? description String?
sortOrder Int @default(0) sortOrder Int @default(0)
status BoardStatus @default(active) status BoardStatus @default(active)
requiresApproval Boolean @default(false) // 게시물 승인 필요 여부
allowAnonymousPost Boolean @default(false) // 익명 글 허용 allowAnonymousPost Boolean @default(false) // 익명 글 허용
allowSecretComment Boolean @default(false) // 비밀댓글 허용 allowSecretComment Boolean @default(false) // 비밀댓글 허용
isAdultOnly Boolean @default(false) // 성인 인증 필요 여부 isAdultOnly Boolean @default(false) // 성인 인증 필요 여부
@@ -218,6 +217,7 @@ model Post {
reports Report[] reports Report[]
stat PostStat? stat PostStat?
viewLogs PostViewLog[] viewLogs PostViewLog[]
dailyViews DailyPostView[]
@@index([boardId, status, createdAt]) @@index([boardId, status, createdAt])
@@index([boardId, isPinned, pinnedOrder]) @@index([boardId, isPinned, pinnedOrder])
@@ -226,12 +226,12 @@ model Post {
// 사용자 // 사용자
model User { model User {
userId String @id @default(cuid()) userId String @id
nickname String @unique nickname String @unique
passwordHash String? passwordHash String?
name String name String
birth DateTime birth DateTime?
phone String @unique phone String? @unique
rank Int @default(0) rank Int @default(0)
// 누적 포인트, 레벨, 등급(0~10) // 누적 포인트, 레벨, 등급(0~10)
points Int @default(0) points Int @default(0)
@@ -260,6 +260,7 @@ model User {
blocksInitiated Block[] @relation("Blocker") blocksInitiated Block[] @relation("Blocker")
blocksReceived Block[] @relation("Blocked") blocksReceived Block[] @relation("Blocked")
pointTxns PointTransaction[] pointTxns PointTransaction[]
attendances Attendance[]
sanctions Sanction[] sanctions Sanction[]
nicknameChanges NicknameChange[] nicknameChanges NicknameChange[]
passwordResetTokens PasswordResetToken[] @relation("PasswordResetUser") passwordResetTokens PasswordResetToken[] @relation("PasswordResetUser")
@@ -325,6 +326,8 @@ model UserRole {
model Comment { model Comment {
id String @id @default(cuid()) id String @id @default(cuid())
postId String postId String
parentId String? // 부모 댓글 ID (null이면 최상위 댓글)
depth Int @default(0) // 댓글 깊이 (0=최상위, 1=1단계 대댓글, 2=2단계 대댓글)
authorId String? authorId String?
content String content String
isAnonymous Boolean @default(false) isAnonymous Boolean @default(false)
@@ -333,11 +336,14 @@ model Comment {
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) parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
reports Report[] replies Comment[] @relation("CommentReplies")
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
reports Report[]
@@index([postId, createdAt]) @@index([postId, createdAt])
@@index([parentId])
@@map("comments") @@map("comments")
} }
@@ -433,6 +439,21 @@ model PostStat {
@@map("post_stats") @@map("post_stats")
} }
// 일일 게시글 조회수 (날짜별 집계)
model DailyPostView {
id String @id @default(cuid())
postId String
date DateTime // 날짜만 사용 (시간은 00:00:00)
viewCount Int @default(0)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@unique([postId, date])
@@index([date, viewCount])
@@index([postId, date])
@@map("daily_post_views")
}
// 신고(게시글/댓글 대상) // 신고(게시글/댓글 대상)
model Report { model Report {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -502,6 +523,20 @@ model PointTransaction {
@@map("point_transactions") @@map("point_transactions")
} }
// 출석부 기록 (사용자별 일자 단위 출석)
model Attendance {
id String @id @default(cuid())
userId String
date DateTime // 자정 기준 날짜만 사용
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [userId], onDelete: Cascade)
@@unique([userId, date])
@@index([userId, date])
@@map("attendance")
}
// 레벨 임계치(선택) // 레벨 임계치(선택)
model LevelThreshold { model LevelThreshold {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -670,6 +705,7 @@ model Partner {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String @unique
category String category String
categoryId String?
latitude Float latitude Float
longitude Float longitude Float
address String? address String?
@@ -678,11 +714,27 @@ model Partner {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
categoryRef PartnerCategory? @relation(fields: [categoryId], references: [id])
@@index([category]) @@index([category])
@@index([categoryId])
@@index([sortOrder]) @@index([sortOrder])
@@map("partners") @@map("partners")
} }
// 제휴업체 카테고리(관리자 생성/삭제)
model PartnerCategory {
id String @id @default(cuid())
name String @unique
sortOrder Int @default(0)
createdAt DateTime @default(now())
partners Partner[]
@@index([sortOrder])
@@map("partner_categories")
}
// 배너/공지 노출용 // 배너/공지 노출용
model Banner { model Banner {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -725,6 +777,10 @@ model PartnerRequest {
longitude Float longitude Float
address String? address String?
contact String? contact String?
region String?
imageUrl String?
sortOrder Int @default(0)
active Boolean @default(true)
status String @default("pending") // pending/approved/rejected status String @default("pending") // pending/approved/rejected
createdAt DateTime @default(now()) createdAt DateTime @default(now())
approvedAt DateTime? approvedAt DateTime?
@@ -745,18 +801,4 @@ model Setting {
} }
// 메인 노출용 제휴 샵 가로 스크롤 데이터 // 메인 노출용 제휴 샵 가로 스크롤 데이터
model PartnerShop { // PartnerShop 모델 제거됨 (PartnerRequest로 통합)
id String @id @default(cuid())
region String
name String
address String
imageUrl String
sortOrder Int @default(0)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([name, region])
@@index([active, sortOrder])
@@map("partner_shops")
}

View File

@@ -1,7 +1,12 @@
const { PrismaClient } = require("@prisma/client"); const { PrismaClient } = require("@prisma/client");
const { createHash } = require("crypto");
const prisma = new PrismaClient(); const prisma = new PrismaClient();
function hashPassword(plain) {
return createHash("sha256").update(plain, "utf8").digest("hex");
}
function randomInt(min, max) { function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min; return Math.floor(Math.random() * (max - min + 1)) + min;
} }
@@ -32,6 +37,75 @@ function generateNickname(i) {
return `user${String(i + 1).padStart(3, "0")}${suffix}`; return `user${String(i + 1).padStart(3, "0")}${suffix}`;
} }
// 랜덤 제목/문장/이미지 도우미
const TITLE_FRAGMENTS = [
// 아주 짧은 키워드
"공지", "업뎃", "버그", "요청", "후기", "정보", "TIP", "사진", "잡담", "나눔",
"질문", "헬프", "리뷰", "이슈", "주의", "긴급", "정리", "모음", "요약", "스샷",
// 짧은 구문
"오늘의 이슈", "핫 토픽", "소소한 일상", "정보 공유", "꿀팁 모음",
"개발 노트", "버그 리포트", "아이디어 제안", "함께 보아요",
];
const SENTENCES = [
"안녕하세요, 간단히 공유 드립니다.",
"도움이 되셨다면 댓글로 알려주세요.",
"의견이나 질문은 언제든 환영입니다.",
"테스트로 작성된 시드 데이터입니다.",
"참고용 스크린샷을 함께 첨부합니다.",
"관련 경험 있으시면 팁 부탁드려요.",
"문서화가 필요해 간단히 정리했습니다.",
"링크와 자료를 함께 첨부합니다.",
"개선 제안은 자유롭게 남겨주세요.",
"읽어주셔서 감사합니다.",
];
const TITLE_SUBS = [
"지금", "방금", "오늘", "금일", "v2", "2025", "베타", "테스트",
"임시", "간단히", "빠르게", "짧게", "새로", "업데이트", "정리", "공유",
];
const TITLE_EMOJIS = ["🔥", "📌", "✅", "❗", "💡", "🆕", "🔧", "📝", "📷"];
function clampTitle(s, max = 60) {
return s.length <= max ? s : s.slice(0, max).trim();
}
function pick(arr) { return arr[randomInt(0, arr.length - 1)]; }
function coin(p = 0.5) { return Math.random() < p; }
function randomTitle(boardName, index) {
// 다양한 템플릿으로 제목 생성 (짧은 것도, 긴 것도 포함)
const a = pick(TITLE_FRAGMENTS);
const b = pick(TITLE_FRAGMENTS);
const sub = pick(TITLE_SUBS);
const emoji = pick(TITLE_EMOJIS);
const num = (index % 99) + 1;
const templates = [
() => `${a}`,
() => `${a} ${emoji}`,
() => `${a} #${num}`,
() => `${a} ${sub}`,
() => `${a} · ${b}`,
() => `[${a}] ${b}`,
() => `${a}: ${b}`,
() => `${a} ${b} ${emoji}`,
// 가끔만 보드명 포함
() => `${boardName} ${a}`,
() => `${boardName} ${a} · ${b}`,
];
// 짧은 제목 확률을 높이기 위해 템플릿 선택 가중치 없이 랜덤
const title = pick(templates)();
return clampTitle(title, 60);
}
function randomSentence() {
return SENTENCES[randomInt(0, SENTENCES.length - 1)];
}
function randomImageUrl(seedKey, w = 800, h = 450) {
// 외부 랜덤 이미지. 네트워크가 제한되면 /sample.jpg로 대체 가능
const seed = encodeURIComponent(String(seedKey));
return `https://picsum.photos/seed/${seed}/${w}/${h}`;
}
async function createRandomUsers(count = 100) { async function createRandomUsers(count = 100) {
const roleUser = await prisma.role.findUnique({ where: { name: "user" } }); const roleUser = await prisma.role.findUnique({ where: { name: "user" } });
// 사용되지 않은 전화번호를 찾는 보조 함수 // 사용되지 않은 전화번호를 찾는 보조 함수
@@ -45,27 +119,48 @@ async function createRandomUsers(count = 100) {
} }
} }
const createdUsers = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
// 닉네임을 결정론적으로 생성해 재실행 시 중복 생성 방지 // 고정 ID: user001, user002, ...
const nickname = `user${String(i + 1).padStart(3, "0")}`; const userId = `user${String(i + 1).padStart(3, "0")}`;
const existing = await prisma.user.findUnique({ where: { nickname } }); const existing = await prisma.user.findUnique({ where: { userId } });
let user = existing; let user = existing;
if (!existing) { if (!existing) {
const name = generateRandomKoreanName(); const name = generateRandomKoreanName();
const birth = randomDate(1975, 2005); const birth = randomDate(1975, 2005);
const phone = await findAvailablePhone(i + 2); // admin이 0001 사용하므로 겹치지 않도록 오프셋 const phone = await findAvailablePhone(i + 2); // admin이 0001 사용하므로 겹치지 않도록 오프셋
// 닉네임: 중복 없는 랜덤 한글 생성
let nickname = generateRandomKoreanName();
for (let tries = 0; tries < 10; tries++) {
const dup = await prisma.user.findUnique({ where: { nickname } });
if (!dup) break;
nickname = generateRandomKoreanName();
}
// 그래도 중복이면 희귀 조합 한 번 더 시도
const finalDup = await prisma.user.findUnique({ where: { nickname } });
if (finalDup) {
nickname = generateRandomKoreanName();
}
user = await prisma.user.create({ user = await prisma.user.create({
data: { data: {
userId,
nickname, nickname,
name, name,
birth, birth,
phone, phone,
passwordHash: hashPassword("12341234"),
agreementTermsAt: new Date(), agreementTermsAt: new Date(),
authLevel: "USER", authLevel: "USER",
isAdultVerified: Math.random() < 0.6, isAdultVerified: Math.random() < 0.6,
lastLoginAt: Math.random() < 0.8 ? new Date() : null, lastLoginAt: Math.random() < 0.8 ? new Date() : null,
}, },
}); });
} else {
// 기존 사용자도 패스워드를 1234로 업데이트
await prisma.user.update({
where: { userId: user.userId },
data: { passwordHash: hashPassword("12341234") },
});
} }
if (roleUser && user) { if (roleUser && user) {
await prisma.userRole.upsert({ await prisma.userRole.upsert({
@@ -74,7 +169,9 @@ async function createRandomUsers(count = 100) {
create: { userId: user.userId, roleId: roleUser.roleId }, create: { userId: user.userId, roleId: roleUser.roleId },
}); });
} }
if (user) createdUsers.push(user);
} }
return createdUsers;
} }
async function upsertCategories() { async function upsertCategories() {
@@ -155,14 +252,24 @@ async function upsertRoles() {
async function upsertAdmin() { async function upsertAdmin() {
const admin = await prisma.user.upsert({ const admin = await prisma.user.upsert({
where: { nickname: "admin" }, where: { nickname: "admin" },
update: {}, update: {
passwordHash: hashPassword("12341234"),
grade: 7,
points: 1650000,
level: 200,
},
create: { create: {
userId: "admin",
nickname: "admin", nickname: "admin",
name: "Administrator", name: "Administrator",
birth: new Date("1990-01-01"), birth: new Date("1990-01-01"),
phone: "010-0000-0001", phone: "010-0000-0001",
passwordHash: hashPassword("12341234"),
agreementTermsAt: new Date(), agreementTermsAt: new Date(),
authLevel: "ADMIN", authLevel: "ADMIN",
grade: 7,
points: 1650000,
level: 200,
}, },
}); });
@@ -179,6 +286,55 @@ async function upsertAdmin() {
return admin; return admin;
} }
async function seedAdminAttendance(admin) {
try {
const now = new Date();
const year = now.getUTCFullYear();
const days = [1, 2, 5, 6]; // 11월 1,2,5,6일
for (const d of days) {
const date = new Date(Date.UTC(year, 10, d, 0, 0, 0, 0)); // 10 = November (0-based)
// @@unique([userId, date]) 기준으로 업서트
await prisma.attendance.upsert({
where: { userId_date: { userId: admin.userId, date } },
update: {},
create: { userId: admin.userId, date },
});
}
} catch (e) {
console.warn("seedAdminAttendance failed:", e);
}
}
async function seedRandomAttendanceForUsers(users, minDays = 10, maxDays = 20) {
try {
const today = new Date();
const todayUtcMidnight = new Date(Date.UTC(
today.getUTCFullYear(),
today.getUTCMonth(),
today.getUTCDate(),
0, 0, 0, 0
));
for (const user of users) {
const count = randomInt(minDays, maxDays);
const used = new Set();
while (used.size < count) {
const offsetDays = randomInt(0, 120); // 최근 120일 범위에서 랜덤
const date = new Date(todayUtcMidnight.getTime() - offsetDays * 24 * 60 * 60 * 1000);
const key = date.toISOString().slice(0, 10); // YYYY-MM-DD
if (used.has(key)) continue;
used.add(key);
await prisma.attendance.upsert({
where: { userId_date: { userId: user.userId, date } },
update: {},
create: { userId: user.userId, date },
});
}
}
} catch (e) {
console.warn("seedRandomAttendanceForUsers failed:", e);
}
}
async function upsertBoards(admin, categoryMap) { async function upsertBoards(admin, categoryMap) {
const boards = [ const boards = [
// 일반 // 일반
@@ -188,7 +344,6 @@ async function upsertBoards(admin, categoryMap) {
{ name: "이벤트", slug: "event", description: "이벤트", sortOrder: 4, requiredTags: { required: ["이벤트"] } }, { name: "이벤트", slug: "event", description: "이벤트", sortOrder: 4, requiredTags: { required: ["이벤트"] } },
{ name: "자유게시판", slug: "free", description: "자유", sortOrder: 5 }, { name: "자유게시판", slug: "free", description: "자유", sortOrder: 5 },
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", sortOrder: 6 }, { name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", sortOrder: 6 },
{ name: "마사지꿀팁", slug: "tips", description: "팁", sortOrder: 7 },
{ name: "익명게시판", slug: "anonymous", description: "익명", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true }, { name: "익명게시판", slug: "anonymous", description: "익명", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true },
{ name: "청와대", slug: "blue-house", description: "레벨 제한", sortOrder: 10, readLevel: "member" }, { name: "청와대", slug: "blue-house", description: "레벨 제한", sortOrder: 10, readLevel: "member" },
// 특수 // 특수
@@ -200,6 +355,13 @@ async function upsertBoards(admin, categoryMap) {
]; ];
const created = []; const created = [];
// 텍스트/특수랭킹/특수출석 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } });
const listText = await prisma.boardViewType.findUnique({ where: { key: "list_text" } });
const mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } });
const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_special_rank" } });
const listSpecialAttendance = await prisma.boardViewType.findUnique({ where: { key: "list_special_attendance" } });
for (const b of boards) { for (const b of boards) {
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리) // 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
const mapBySlug = { const mapBySlug = {
@@ -227,20 +389,30 @@ async function upsertBoards(admin, categoryMap) {
update: { update: {
description: b.description, description: b.description,
sortOrder: b.sortOrder, sortOrder: b.sortOrder,
requiresApproval: !!b.requiresApproval,
allowAnonymousPost: !!b.allowAnonymousPost, allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined, readLevel: b.readLevel || undefined,
categoryId: category ? category.id : undefined, categoryId: category ? category.id : undefined,
// 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드
...(mainText ? { mainPageViewTypeId: mainText.id } : {}),
...(listText ? { listViewTypeId: listText.id } : {}),
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
}, },
create: { create: {
name: b.name, name: b.name,
slug: b.slug, slug: b.slug,
description: b.description, description: b.description,
sortOrder: b.sortOrder, sortOrder: b.sortOrder,
requiresApproval: !!b.requiresApproval,
allowAnonymousPost: !!b.allowAnonymousPost, allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined, readLevel: b.readLevel || undefined,
categoryId: category ? category.id : undefined, categoryId: category ? category.id : undefined,
// 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드
...(mainText ? { mainPageViewTypeId: mainText.id } : {}),
...(listText ? { listViewTypeId: listText.id } : {}),
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
}, },
}); });
created.push(board); created.push(board);
@@ -261,16 +433,15 @@ async function upsertBoards(admin, categoryMap) {
async function upsertViewTypes() { async function upsertViewTypes() {
const viewTypes = [ const viewTypes = [
// main scope // main scope (기본/없음 제거, 텍스트 중심)
{ key: "main_default", name: "기본", scope: "main" },
{ key: "main_text", name: "텍스트", scope: "main" }, { key: "main_text", name: "텍스트", scope: "main" },
{ key: "main_preview", name: "미리보기", scope: "main" }, { key: "main_preview", name: "미리보기", scope: "main" },
{ key: "main_special_rank", name: "특수랭킹", scope: "main" }, { key: "main_special_rank", name: "특수랭킹", scope: "main" },
// list scope // list scope (기본/없음 제거, 텍스트 중심)
{ key: "list_default", name: "기본", scope: "list" },
{ key: "list_text", name: "텍스트", scope: "list" }, { key: "list_text", name: "텍스트", scope: "list" },
{ key: "list_preview", name: "미리보기", scope: "list" }, { key: "list_preview", name: "미리보기", scope: "list" },
{ key: "list_special_rank", name: "특수랭킹", scope: "list" }, { key: "list_special_rank", name: "특수랭킹", scope: "list" },
{ key: "list_special_attendance", name: "특수출석", scope: "list" },
]; ];
for (const vt of viewTypes) { for (const vt of viewTypes) {
await prisma.boardViewType.upsert({ await prisma.boardViewType.upsert({
@@ -292,16 +463,23 @@ async function createPostsForAllBoards(boards, countPerBoard = 100, admin) {
const users = await prisma.user.findMany({ select: { userId: true } }); const users = await prisma.user.findMany({ select: { userId: true } });
const userIds = users.map((u) => u.userId); const userIds = users.map((u) => u.userId);
for (const board of boards) { for (const board of boards) {
// 회원랭킹 보드는 특수랭킹용이라 게시글을 시드하지 않습니다.
if (board.slug === "ranking") continue;
const data = []; const data = [];
for (let i = 0; i < countPerBoard; i++) { for (let i = 0; i < countPerBoard; i++) {
const authorId = ["notice", "bug-report"].includes(board.slug) const authorId = ["notice", "bug-report"].includes(board.slug)
? admin.userId ? admin.userId
: userIds[randomInt(0, userIds.length - 1)]; : userIds[randomInt(0, userIds.length - 1)];
const title = randomTitle(board.name, i);
const img = randomImageUrl(`${board.slug}-${i}`);
const p1 = randomSentence();
const p2 = randomSentence();
const p3 = randomSentence();
data.push({ data.push({
boardId: board.id, boardId: board.id,
authorId, authorId,
title: `${board.name} 샘플 글 ${i + 1}`, title,
content: `이 게시판(${board.slug})의 자동 시드 게시물 #${i + 1} 입니다.\n\n테스트용 내용입니다.`, content: `<p>${p1}</p>\n<figure><img src="${img}" alt="seed image" /></figure>\n<p>${p2}</p>\n<p>${p3}</p>`,
status: "published", status: "published",
isAnonymous: board.allowAnonymousPost ? Math.random() < 0.3 : false, isAnonymous: board.allowAnonymousPost ? Math.random() < 0.3 : false,
}); });
@@ -346,10 +524,10 @@ async function seedPartnerShops() {
{ region: "강원도", name: "test2", address: "춘천시 중앙로 112", imageUrl: "/sample.jpg", sortOrder: 2 }, { region: "강원도", name: "test2", address: "춘천시 중앙로 112", imageUrl: "/sample.jpg", sortOrder: 2 },
]; ];
for (const it of items) { for (const it of items) {
await prisma.partnerShop.upsert({ await prisma.partnerRequest.upsert({
where: { name_region: { name: it.name, region: it.region } }, where: { id: `${it.region}-${it.name}` },
update: { address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true }, update: { address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true, status: "approved" },
create: it, create: { id: `${it.region}-${it.name}`, region: it.region, name: it.name, address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true, status: "approved", category: "misc", latitude: 37.5, longitude: 127.0 },
}); });
} }
// 표시 토글 기본값 보장 // 표시 토글 기본값 보장
@@ -364,16 +542,112 @@ async function seedPartnerShops() {
}); });
} }
async function seedBanners() {
// 기존 배너 정리 후 5개만 채움
await prisma.banner.deleteMany({});
const items = Array.from({ length: 5 }).map((_, i) => ({
title: `메인 배너 ${i + 1}`,
imageUrl: "/sample.jpg",
linkUrl: "/",
sortOrder: i + 1,
active: true,
}));
await prisma.banner.createMany({ data: items });
}
async function seedMainpageVisibleBoards(boards) {
const SETTINGS_KEY = "mainpage_settings";
const setting = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
const current = setting ? JSON.parse(setting.value) : {};
const wantSlugs = new Set(["notice", "free", "ranking"]);
const visibleBoardIds = boards.filter((b) => wantSlugs.has(b.slug)).map((b) => b.id);
const next = { ...current, visibleBoardIds };
await prisma.setting.upsert({
where: { key: SETTINGS_KEY },
update: { value: JSON.stringify(next) },
create: { key: SETTINGS_KEY, value: JSON.stringify(next) },
});
}
async function main() { async function main() {
console.log("DATABASE_URL:", process.env.DATABASE_URL);
try {
const tables = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table'`;
console.log("SQLite tables:", tables.map((t) => t.name || t.NAME || JSON.stringify(t)));
} catch {}
// SQLite 수동 보정: partner_categories 테이블과 partners.categoryId 컬럼 보장
try {
const rows = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table' AND name='partner_categories'`;
if (!Array.isArray(rows) || rows.length === 0) {
console.log("Creating missing table: partner_categories");
await prisma.$executeRawUnsafe(
"CREATE TABLE IF NOT EXISTS partner_categories (\n" +
"id TEXT PRIMARY KEY,\n" +
"name TEXT NOT NULL UNIQUE,\n" +
"sortOrder INTEGER NOT NULL DEFAULT 0,\n" +
"createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n" +
")"
);
await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_partner_categories_sortOrder ON partner_categories(sortOrder)");
}
// Attendance 테이블 보장 (마이그레이션 미실행 환경 대응)
const att = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table' AND name='attendance'`;
if (!Array.isArray(att) || att.length === 0) {
console.log("Creating missing table: attendance");
await prisma.$executeRawUnsafe(
"CREATE TABLE IF NOT EXISTS attendance (\n" +
"id TEXT PRIMARY KEY,\n" +
"userId TEXT NOT NULL,\n" +
"date DATETIME NOT NULL,\n" +
"createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n" +
")"
);
await prisma.$executeRawUnsafe("CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_user_date ON attendance(userId, date)");
await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_attendance_date ON attendance(date)");
}
const cols = await prisma.$queryRaw`PRAGMA table_info('partners')`;
const hasCategoryId = Array.isArray(cols) && cols.some((c) => (c.name || c.COLUMN_NAME) === 'categoryId');
if (!hasCategoryId) {
console.log("Adding missing column: partners.categoryId");
await prisma.$executeRawUnsafe("ALTER TABLE partners ADD COLUMN categoryId TEXT");
// 외래키 제약은 생략 (SQLite에서는 제약 추가가 까다로움)
}
// partner_requests 확장 컬럼 보장 (region, imageUrl, sortOrder, active)
const prCols = await prisma.$queryRaw`PRAGMA table_info('partner_requests')`;
const colHas = (name) => Array.isArray(prCols) && prCols.some((c) => (c.name || c.COLUMN_NAME) === name);
if (!colHas('region')) {
console.log("Adding missing column: partner_requests.region");
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN region TEXT");
}
if (!colHas('imageUrl')) {
console.log("Adding missing column: partner_requests.imageUrl");
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN imageUrl TEXT");
}
if (!colHas('sortOrder')) {
console.log("Adding missing column: partner_requests.sortOrder");
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN sortOrder INTEGER NOT NULL DEFAULT 0");
}
if (!colHas('active')) {
console.log("Adding missing column: partner_requests.active");
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN active BOOLEAN NOT NULL DEFAULT 1");
}
} catch (e) {
console.warn("SQLite schema ensure failed:", e);
}
await upsertRoles(); await upsertRoles();
const admin = await upsertAdmin(); const admin = await upsertAdmin();
const categoryMap = await upsertCategories(); const categoryMap = await upsertCategories();
await upsertViewTypes(); await upsertViewTypes();
await createRandomUsers(100); const randomUsers = await createRandomUsers(3);
await seedRandomAttendanceForUsers(randomUsers, 10, 20);
await removeNonPrimaryBoards(); await removeNonPrimaryBoards();
const boards = await upsertBoards(admin, categoryMap); const boards = await upsertBoards(admin, categoryMap);
await seedAdminAttendance(admin);
await seedMainpageVisibleBoards(boards);
await createPostsForAllBoards(boards, 100, admin); await createPostsForAllBoards(boards, 100, admin);
await seedPartnerShops(); await seedPartnerShops();
await seedBanners();
await seedPolicies(); await seedPolicies();
@@ -382,8 +656,22 @@ async function main() {
{ name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" }, { name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
{ name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" }, { name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" },
]; ];
// 파트너 카테고리(PartnerCategory) 생성 및 매핑
const partnerCategoryNames = Array.from(new Set(partners.map((p) => p.category).filter(Boolean)));
const partnerCategoryMap = {};
for (let i = 0; i < partnerCategoryNames.length; i++) {
const name = partnerCategoryNames[i];
const created = await prisma.partnerCategory.upsert({
where: { name },
update: { sortOrder: i + 1 },
create: { name, sortOrder: i + 1 },
});
partnerCategoryMap[name] = created;
}
for (const p of partners) { for (const p of partners) {
await prisma.partner.upsert({ where: { name: p.name }, update: p, create: p }); const categoryRef = p.category ? partnerCategoryMap[p.category] : null;
const data = { ...p, categoryId: categoryRef ? categoryRef.id : null };
await prisma.partner.upsert({ where: { name: p.name }, update: data, create: data });
} }
} }

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 69 KiB

9
public/svgs/03_gold.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 70 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 92 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 95 KiB

9
public/svgs/08_god.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

@@ -15,7 +15,7 @@ const navItems = [
export default function AdminSidebar() { export default function AdminSidebar() {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<aside className="w-56 shrink-0 border-r border-neutral-200 bg-white/80 backdrop-blur h-full"> <aside className="w-56 shrink-0 border-r border-neutral-200 bg-white/90 backdrop-blur h-full">
<div className="px-4 py-4 border-b border-neutral-200"> <div className="px-4 py-4 border-b border-neutral-200">
<Link href="/admin" className="block text-lg font-bold text-neutral-900"></Link> <Link href="/admin" className="block text-lg font-bold text-neutral-900"></Link>
</div> </div>

View File

@@ -33,6 +33,8 @@ export default function AdminBoardsPage() {
const listTypes = useMemo(() => viewTypes.filter((v: any) => v.scope === 'list'), [viewTypes]); const listTypes = useMemo(() => viewTypes.filter((v: any) => v.scope === 'list'), [viewTypes]);
const defaultMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_default' || t.key === 'default')?.id ?? null), [mainTypes]); const defaultMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_default' || t.key === 'default')?.id ?? null), [mainTypes]);
const defaultListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_default' || t.key === 'default')?.id ?? null), [listTypes]); const defaultListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_default' || t.key === 'default')?.id ?? null), [listTypes]);
const textMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_text')?.id ?? null), [mainTypes]);
const textListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_text')?.id ?? null), [listTypes]);
const categories = useMemo(() => { const categories = useMemo(() => {
const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) })); const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) }));
return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
@@ -237,7 +239,7 @@ export default function AdminBoardsPage() {
return; return;
} }
const sortOrder = (currentItems?.length ?? 0) + 1; const sortOrder = (currentItems?.length ?? 0) + 1;
await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: defaultMainTypeId, listViewTypeId: defaultListTypeId }) }); await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: textMainTypeId, listViewTypeId: textListTypeId }) });
await mutateBoards(); await mutateBoards();
} }
@@ -368,7 +370,6 @@ export default function AdminBoardsPage() {
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
<th className="px-3 py-2"> </th> <th className="px-3 py-2"> </th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
@@ -437,12 +438,20 @@ export default function AdminBoardsPage() {
function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, mainTypes, listTypes, onAddType, defaultMainTypeId, defaultListTypeId }: { b: any; onDirty: (id: string, draft: any) => void; onDelete: () => void; allowMove?: boolean; categories?: any[]; onMove?: (toId: string) => void; mainTypes?: any[]; listTypes?: any[]; onAddType?: (scope: 'main'|'list') => Promise<string | null>; defaultMainTypeId?: string | null; defaultListTypeId?: string | null }) { function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, mainTypes, listTypes, onAddType, defaultMainTypeId, defaultListTypeId }: { b: any; onDirty: (id: string, draft: any) => void; onDelete: () => void; allowMove?: boolean; categories?: any[]; onMove?: (toId: string) => void; mainTypes?: any[]; listTypes?: any[]; onAddType?: (scope: 'main'|'list') => Promise<string | null>; defaultMainTypeId?: string | null; defaultListTypeId?: string | null }) {
const [edit, setEdit] = useState(b); const [edit, setEdit] = useState(b);
const effectiveMainTypeId = edit.mainPageViewTypeId ?? defaultMainTypeId ?? ''; // 선택 가능 옵션에서 '기본' 타입은 제외
const effectiveListTypeId = edit.listViewTypeId ?? defaultListTypeId ?? ''; const selectableMainTypes = (mainTypes ?? []).filter((t: any) => t.id !== defaultMainTypeId);
const selectableListTypes = (listTypes ?? []).filter((t: any) => t.id !== defaultListTypeId);
// 표시 값: 현재 값이 선택 가능 목록에 없으면 첫 번째 항목을 사용
const effectiveMainTypeId = selectableMainTypes.some((t: any) => t.id === edit.mainPageViewTypeId)
? edit.mainPageViewTypeId
: (selectableMainTypes[0]?.id ?? '');
const effectiveListTypeId = selectableListTypes.some((t: any) => t.id === edit.listViewTypeId)
? edit.listViewTypeId
: (selectableListTypes[0]?.id ?? '');
return ( return (
<> <>
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td> <td className="px-3 py-2"><input className="h-9 w-full min-w-[160px] rounded-md border border-neutral-300 px-2 text-sm" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td> <td className="px-3 py-2"><input className="h-9 w-full min-w-[200px] rounded-md border border-neutral-300 px-2 text-sm" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center"> <td className="px-3 py-2 text-center">
<select <select
className="h-9 rounded-md border border-neutral-300 px-2 text-sm" className="h-9 rounded-md border border-neutral-300 px-2 text-sm"
@@ -454,12 +463,11 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
e.currentTarget.value = id ?? ''; e.currentTarget.value = id ?? '';
return; return;
} }
const v = { ...edit, mainPageViewTypeId: e.target.value || null }; const v = { ...edit, mainPageViewTypeId: e.target.value };
setEdit(v); onDirty(b.id, v); setEdit(v); onDirty(b.id, v);
}} }}
> >
<option value="">()</option> {(selectableMainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
{(mainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
<option value="__add__">+ </option> <option value="__add__">+ </option>
</select> </select>
</td> </td>
@@ -474,12 +482,11 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
e.currentTarget.value = id ?? ''; e.currentTarget.value = id ?? '';
return; return;
} }
const v = { ...edit, listViewTypeId: e.target.value || null }; const v = { ...edit, listViewTypeId: e.target.value };
setEdit(v); onDirty(b.id, v); setEdit(v); onDirty(b.id, v);
}} }}
> >
<option value="">()</option> {(selectableListTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
{(listTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
<option value="__add__">+ </option> <option value="__add__">+ </option>
</select> </select>
</td> </td>
@@ -501,7 +508,6 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
</td> </td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td> <td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td> <td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.requiresApproval} onChange={(e) => { const v = { ...edit, requiresApproval: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td> <td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
{allowMove && categories && onMove ? ( {allowMove && categories && onMove ? (
<td className="px-3 py-2 text-center"> <td className="px-3 py-2 text-center">
@@ -553,8 +559,8 @@ function CategoryHeaderContent({ g, onDirty }: { g: any; onDirty: (payload: any)
return ( return (
<> <>
<div className="w-10" /> <div className="w-10" />
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} /> <input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48 min-w-[160px]" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} />
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} /> <input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48 min-w-[200px]" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
<div className="flex-1" /> <div className="flex-1" />
</> </>
); );

View File

@@ -1,11 +1,32 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import AdminSidebar from "@/app/admin/AdminSidebar"; import AdminSidebar from "@/app/admin/AdminSidebar";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Admin | ASSM", title: "Admin | ASSM",
}; };
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default async function AdminLayout({ children }: { children: React.ReactNode }) {
// 서버에서 쿠키 기반 접근 제어 (미들웨어 보조)
const h = await headers();
const cookieHeader = h.get("cookie") || "";
const uid = cookieHeader
.split(";")
.map((s) => s.trim())
.find((pair) => pair.startsWith("uid="))
?.split("=")[1];
const isAdmin = cookieHeader
.split(";")
.map((s) => s.trim())
.find((pair) => pair.startsWith("isAdmin="))
?.split("=")[1];
if (!uid) {
redirect("/login");
}
if (isAdmin !== "1") {
redirect("/");
}
return ( return (
<div className="min-h-[calc(100vh-0px)] flex"> <div className="min-h-[calc(100vh-0px)] flex">
<AdminSidebar /> <AdminSidebar />

View File

@@ -7,8 +7,10 @@ const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function AdminPartnersPage() { export default function AdminPartnersPage() {
const { data, mutate } = useSWR<{ partners: any[] }>("/api/admin/partners", fetcher); const { data, mutate } = useSWR<{ partners: any[] }>("/api/admin/partners", fetcher);
const { data: catData, mutate: mutateCategories } = useSWR<{ categories: any[] }>("/api/admin/partner-categories", fetcher);
const partners = data?.partners ?? []; const partners = data?.partners ?? [];
const [form, setForm] = useState({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" }); const categories = catData?.categories ?? [];
const [form, setForm] = useState({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const editFileInputRef = useRef<HTMLInputElement>(null); const editFileInputRef = useRef<HTMLInputElement>(null);
@@ -62,8 +64,8 @@ export default function AdminPartnersPage() {
async function create() { async function create() {
// 필수값 검증: 이름/카테고리/위도/경도 // 필수값 검증: 이름/카테고리/위도/경도
if (!form.name || !form.category || !String(form.latitude).trim() || !String(form.longitude).trim()) { if (!form.name || !form.categoryId || !String(form.latitude).trim() || !String(form.longitude).trim()) {
alert("이름, 카테고리, 위도, 경도를 모두 입력해 주세요."); alert("이름, 카테고리, 위도, 경도를 모두 선택/입력해 주세요.");
return; return;
} }
const lat = Number(form.latitude); const lat = Number(form.latitude);
@@ -72,12 +74,21 @@ export default function AdminPartnersPage() {
alert("위도/경도는 숫자여야 합니다."); alert("위도/경도는 숫자여야 합니다.");
return; return;
} }
const payload = { ...form, latitude: lat, longitude: lon } as any; const payload = { ...form, latitude: lat, longitude: lon, categoryId: form.categoryId } as any;
const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) }); const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
if (r.ok) { if (r.ok) {
setForm({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" }); setForm({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
mutate(); mutate();
setShowCreateModal(false); setShowCreateModal(false);
} else {
let msg = "저장에 실패했습니다.";
try {
const j = await r.json();
msg = j?.message || j?.error || msg;
if (r.status === 409 && j?.error === "duplicate_name") msg = "이미 존재하는 업체명입니다.";
if (r.status === 400) msg = msg || "입력값을 확인해 주세요.";
} catch {}
alert(msg);
} }
} }
@@ -107,7 +118,12 @@ export default function AdminPartnersPage() {
</div> </div>
<div style={{ gridColumn: "span 3" }}> <div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label> <label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} /> <select style={inputStyle} value={form.categoryId} onChange={(e) => setForm({ ...form, categoryId: e.target.value })}>
<option value="">()</option>
{categories.map((c: any) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div> </div>
<div style={{ gridColumn: "span 3" }}> <div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label> <label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
@@ -178,7 +194,12 @@ export default function AdminPartnersPage() {
</div> </div>
<div style={{ gridColumn: "span 3" }}> <div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label> <label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.category ?? ""} onChange={(e) => setEditDraft({ ...editDraft, category: e.target.value })} /> <select style={inputStyle} value={editDraft?.categoryId ?? ""} onChange={(e) => setEditDraft({ ...editDraft, categoryId: e.target.value })}>
<option value="">()</option>
{categories.map((c: any) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div> </div>
<div style={{ gridColumn: "span 3" }}> <div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label> <label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
@@ -216,7 +237,23 @@ export default function AdminPartnersPage() {
)} )}
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}> <div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
<button <button
onClick={async () => { if (!editingId) return; await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft }) }); setEditingId(null); setEditDraft(null); setShowEditModal(false); mutate(); }} onClick={async () => {
if (!editingId) return;
const resp = await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft, categoryId: (editDraft?.categoryId || null) }) });
if (!resp.ok) {
let msg = "저장에 실패했습니다.";
try {
const j = await resp.json();
msg = j?.message || j?.error || msg;
} catch {}
alert(msg);
return;
}
setEditingId(null);
setEditDraft(null);
setShowEditModal(false);
mutate();
}}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition" className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
> >
@@ -250,14 +287,14 @@ export default function AdminPartnersPage() {
) : ( ) : (
<> <>
<div> <div>
<div><strong>{p.name}</strong> <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>{p.category}</span></div> <div><strong>{p.name}</strong> {p.categoryRef ? <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>[{p.categoryRef.name}]</span> : null}</div>
<div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div> <div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div>
</div> </div>
<div style={{ fontSize: 12, opacity: .7 }}> {p.latitude}</div> <div style={{ fontSize: 12, opacity: .7 }}> {p.latitude}</div>
<div style={{ fontSize: 12, opacity: .7 }}> {p.longitude}</div> <div style={{ fontSize: 12, opacity: .7 }}> {p.longitude}</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button <button
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, category: p.category, latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }} onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, categoryId: p.categoryId ?? "", latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }}
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition" className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
> >
@@ -288,8 +325,49 @@ export default function AdminPartnersPage() {
</li> </li>
))} ))}
</ul> </ul>
<div>
<hr style={{ margin: "24px 0" }} />
<h2 className="text-lg font-bold mb-2"> </h2>
<CategoryManager categories={categories} onChanged={mutateCategories} />
</div>
</div> </div>
); );
} }
function CategoryManager({ categories, onChanged }: { categories: any[]; onChanged: () => void }) {
const [name, setName] = useState("");
return (
<div className="border border-neutral-200 rounded-lg p-3">
<div className="flex items-center gap-2 mb-3">
<input
placeholder="카테고리 이름"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-9 px-3 rounded-md border border-neutral-300 flex-1"
/>
<button
onClick={async () => { if (!name.trim()) return; const r = await fetch("/api/admin/partner-categories", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: name.trim() }) }); if (r.ok) { setName(""); onChanged(); } else { try { const j = await r.json(); alert(j?.message || j?.error || "생성 실패"); } catch { alert("생성 실패"); } } }}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
>
</button>
</div>
<ul className="flex flex-wrap gap-2">
{categories.map((c: any) => (
<li key={c.id} className="flex items-center gap-2 px-2 py-1 rounded-full border border-neutral-300 text-sm">
<span>{c.name}</span>
<button
title="삭제"
onClick={async () => { const r = await fetch(`/api/admin/partner-categories/${c.id}`, { method: "DELETE" }); if (r.ok) onChanged(); else { try { const j = await r.json(); alert(j?.message || j?.error || "삭제 실패"); } catch { alert("삭제 실패"); } } }}
className="px-2 h-6 rounded-md border border-red-200 text-red-600 hover:bg-red-100 hover:border-red-300 hover:text-red-700"
>
</button>
</li>
))}
</ul>
</div>
);
}

View File

@@ -47,6 +47,7 @@ export default function AdminUsersPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-[#f6f4f4] border-b border-[#e6e6e6]"> <thead className="bg-[#f6f4f4] border-b border-[#e6e6e6]">
<tr> <tr>
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">ID</th>
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]"></th> <th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]"></th> <th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]"></th> <th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]"></th>
@@ -90,6 +91,7 @@ function Row({ u, onChanged }: { u: any; onChanged: () => void }) {
const allRoles = ["admin", "editor", "user"] as const; const allRoles = ["admin", "editor", "user"] as const;
return ( return (
<tr className="hover:bg-neutral-50"> <tr className="hover:bg-neutral-50">
<td className="px-4 py-2 text-left tabular-nums">{u.userId}</td>
<td className="px-4 py-2 text-left">{u.nickname}</td> <td className="px-4 py-2 text-left">{u.nickname}</td>
<td className="px-4 py-2 text-left">{u.name}</td> <td className="px-4 py-2 text-left">{u.name}</td>
<td className="px-4 py-2 text-left">{u.phone}</td> <td className="px-4 py-2 text-left">{u.phone}</td>

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import type { Prisma } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
export async function GET() { export async function GET() {
@@ -24,7 +25,7 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body); const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const banner = await prisma.banner.create({ data: parsed.data }); const banner = await prisma.banner.create({ data: parsed.data as Prisma.BannerCreateInput });
return NextResponse.json({ banner }, { status: 201 }); return NextResponse.json({ banner }, { status: 201 });
} }

View File

@@ -5,7 +5,7 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
const { id } = await context.params; const { id } = await context.params;
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const data: any = {}; const data: any = {};
for (const k of ["name", "slug", "description", "sortOrder", "readLevel", "writeLevel", "allowAnonymousPost", "allowSecretComment", "requiresApproval", "status", "isAdultOnly", "categoryId", "mainPageViewTypeId", "listViewTypeId"]) { for (const k of ["name", "slug", "description", "sortOrder", "readLevel", "writeLevel", "allowAnonymousPost", "allowSecretComment", "status", "isAdultOnly", "categoryId", "mainPageViewTypeId", "listViewTypeId"]) {
if (k in body) data[k] = body[k]; if (k in body) data[k] = body[k];
} }
if ("requiredTags" in body) { if ("requiredTags" in body) {

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import type { Prisma } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
export async function GET() { export async function GET() {
@@ -16,7 +17,6 @@ export async function GET() {
writeLevel: true, writeLevel: true,
allowAnonymousPost: true, allowAnonymousPost: true,
allowSecretComment: true, allowSecretComment: true,
requiresApproval: true,
status: true, status: true,
categoryId: true, categoryId: true,
mainPageViewTypeId: true, mainPageViewTypeId: true,
@@ -36,7 +36,6 @@ const createSchema = z.object({
writeLevel: z.string().optional(), writeLevel: z.string().optional(),
allowAnonymousPost: z.boolean().optional(), allowAnonymousPost: z.boolean().optional(),
allowSecretComment: z.boolean().optional(), allowSecretComment: z.boolean().optional(),
requiresApproval: z.boolean().optional(),
status: z.string().optional(), status: z.string().optional(),
isAdultOnly: z.boolean().optional(), isAdultOnly: z.boolean().optional(),
categoryId: z.string().nullable().optional(), categoryId: z.string().nullable().optional(),
@@ -56,7 +55,13 @@ export async function POST(req: Request) {
}); });
sortOrder = (max._max.sortOrder ?? 0) + 1; sortOrder = (max._max.sortOrder ?? 0) + 1;
} }
const created = await prisma.board.create({ data: { ...data, sortOrder } }); const { categoryId, sortOrder: _ignored, ...rest } = data;
const createData: Prisma.BoardCreateInput = {
...(rest as any),
sortOrder,
...(categoryId ? { category: { connect: { id: categoryId } } } : {}),
};
const created = await prisma.board.create({ data: createData });
return NextResponse.json({ board: created }, { status: 201 }); return NextResponse.json({ board: created }, { status: 201 });
} }

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { z } from "zod"; import { z } from "zod";
import type { Prisma } from "@prisma/client";
export async function GET() { export async function GET() {
const categories = await prisma.boardCategory.findMany({ const categories = await prisma.boardCategory.findMany({
@@ -20,7 +21,7 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body); const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const category = await prisma.boardCategory.create({ data: parsed.data }); const category = await prisma.boardCategory.create({ data: parsed.data as Prisma.BoardCategoryCreateInput });
return NextResponse.json({ category }, { status: 201 }); return NextResponse.json({ category }, { status: 201 });
} }

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
const body = await req.json().catch(() => ({}));
const data: any = {};
for (const k of ["name", "sortOrder"]) if (k in body) data[k] = body[k];
try {
const category = await prisma.partnerCategory.update({ where: { id }, data });
return NextResponse.json({ category });
} catch (e: any) {
if (e?.code === 'P2002') return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 카테고리입니다.' }, { status: 409 });
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
}
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
try {
await prisma.partnerCategory.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (e: any) {
return NextResponse.json({ error: 'category_in_use', message: '해당 카테고리를 사용하는 제휴업체가 있습니다.' }, { status: 409 });
}
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
export async function GET() {
const categories = await prisma.partnerCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }] });
return NextResponse.json({ categories });
}
const createSchema = z.object({ name: z.string().min(1), sortOrder: z.coerce.number().int().optional() });
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
try {
const category = await prisma.partnerCategory.create({ data: { name: parsed.data.name, sortOrder: parsed.data.sortOrder ?? 0 } });
return NextResponse.json({ category }, { status: 201 });
} catch (e: any) {
if (e?.code === 'P2002') return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 카테고리입니다.' }, { status: 409 });
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
}
}

View File

@@ -8,13 +8,13 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
for (const k of ["region", "name", "address", "imageUrl", "active", "sortOrder"]) { for (const k of ["region", "name", "address", "imageUrl", "active", "sortOrder"]) {
if (k in body) data[k] = body[k]; if (k in body) data[k] = body[k];
} }
const item = await prisma.partnerShop.update({ where: { id }, data }); const item = await prisma.partnerRequest.update({ where: { id }, data });
return NextResponse.json({ item }); return NextResponse.json({ item });
} }
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) { export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params; const { id } = await context.params;
await prisma.partnerShop.delete({ where: { id } }); await prisma.partnerRequest.delete({ where: { id } });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }

View File

@@ -1,10 +1,15 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { z } from "zod"; import { z } from "zod";
import type { Prisma } from "@prisma/client";
export async function GET() { export async function GET() {
const items = await prisma.partnerShop.findMany({ orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }] }); const rows = await prisma.partnerRequest.findMany({
return NextResponse.json({ items }); where: { status: "approved" },
orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }],
select: { id: true, region: true, name: true, address: true, imageUrl: true, sortOrder: true, active: true },
});
return NextResponse.json({ items: rows });
} }
const createSchema = z.object({ const createSchema = z.object({
@@ -20,7 +25,8 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body); const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const item = await prisma.partnerShop.create({ data: parsed.data }); // 통합: 승인된 레코드로 등록
const item = await prisma.partnerRequest.create({ data: { ...(parsed.data as any), status: "approved" } });
return NextResponse.json({ item }, { status: 201 }); return NextResponse.json({ item }, { status: 201 });
} }

View File

@@ -5,12 +5,19 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
const { id } = await context.params; const { id } = await context.params;
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const data: any = {}; const data: any = {};
for (const k of ["name", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder"]) { for (const k of ["name", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder", "categoryId"]) {
if (k in body) data[k] = body[k]; if (k in body) data[k] = body[k];
} }
if (typeof data.latitude !== "undefined") data.latitude = Number(data.latitude); if (typeof data.latitude !== "undefined") data.latitude = Number(data.latitude);
if (typeof data.longitude !== "undefined") data.longitude = Number(data.longitude); if (typeof data.longitude !== "undefined") data.longitude = Number(data.longitude);
if (typeof data.sortOrder !== "undefined") data.sortOrder = Number(data.sortOrder); if (typeof data.sortOrder !== "undefined") data.sortOrder = Number(data.sortOrder);
// categoryId가 들어왔고 category 문자열이 비어있으면 카테고리명으로 채움
if (typeof data.categoryId !== "undefined" && (typeof data.category === "undefined" || data.category === null)) {
if (data.categoryId) {
const cat = await prisma.partnerCategory.findUnique({ where: { id: String(data.categoryId) } });
if (cat) data.category = cat.name;
}
}
try { try {
const partner = await prisma.partner.update({ where: { id }, data }); const partner = await prisma.partner.update({ where: { id }, data });
return NextResponse.json({ partner }); return NextResponse.json({ partner });

View File

@@ -4,19 +4,19 @@ import { z } from "zod";
export async function GET() { export async function GET() {
try { try {
// 정렬용 컬럼(sortOrder)이 있는 경우 우선 사용 // 카테고리 조인 포함
const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] }); const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], include: { categoryRef: true } });
return NextResponse.json({ partners }); return NextResponse.json({ partners });
} catch (_) { } catch (_) {
// 컬럼이 아직 DB에 없으면 createdAt 기준으로 폴백 // 컬럼이 아직 DB에 없으면 createdAt 기준으로 폴백
const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" } }); const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" }, include: { categoryRef: true } });
return NextResponse.json({ partners }); return NextResponse.json({ partners });
} }
} }
const createSchema = z.object({ const createSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
category: z.string().min(1), category: z.string().min(1).optional(),
latitude: z.coerce.number(), latitude: z.coerce.number(),
longitude: z.coerce.number(), longitude: z.coerce.number(),
address: z.string().min(1).optional(), address: z.string().min(1).optional(),
@@ -27,14 +27,28 @@ const createSchema = z.object({
}) })
.optional(), .optional(),
sortOrder: z.coerce.number().int().optional(), sortOrder: z.coerce.number().int().optional(),
categoryId: z.string().min(1),
}); });
export async function POST(req: Request) { export async function POST(req: Request) {
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body); const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const partner = await prisma.partner.create({ data: parsed.data as any }); try {
return NextResponse.json({ partner }, { status: 201 }); const { categoryId } = parsed.data as any;
const cat = await prisma.partnerCategory.findUnique({ where: { id: categoryId } });
if (!cat) return NextResponse.json({ error: 'invalid_category', message: '유효하지 않은 카테고리입니다.' }, { status: 400 });
const data: any = { ...parsed.data };
if (!data.category) data.category = cat.name;
const partner = await prisma.partner.create({ data });
return NextResponse.json({ partner }, { status: 201 });
} catch (e: any) {
// Unique name 에러 처리
if (e?.code === 'P2002') {
return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 업체명입니다.' }, { status: 409 });
}
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
}
} }

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { z } from "zod"; import { z } from "zod";
import type { Prisma } from "@prisma/client";
export async function GET() { export async function GET() {
const items = await prisma.boardViewType.findMany({ orderBy: [{ scope: 'asc' }, { name: 'asc' }] }); const items = await prisma.boardViewType.findMany({ orderBy: [{ scope: 'asc' }, { name: 'asc' }] });
@@ -19,7 +20,7 @@ export async function POST(req: Request) {
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const exists = await prisma.boardViewType.findFirst({ where: { key: parsed.data.key } }); const exists = await prisma.boardViewType.findFirst({ where: { key: parsed.data.key } });
if (exists) return NextResponse.json({ error: 'duplicate_key' }, { status: 409 }); if (exists) return NextResponse.json({ error: 'duplicate_key' }, { status: 409 });
const created = await prisma.boardViewType.create({ data: parsed.data }); const created = await prisma.boardViewType.create({ data: parsed.data as Prisma.BoardViewTypeCreateInput });
return NextResponse.json({ item: created }, { status: 201 }); return NextResponse.json({ item: created }, { status: 201 });
} }

View File

@@ -0,0 +1,77 @@
export const dynamic = "force-dynamic";
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { getUserIdFromRequest } from "@/lib/auth";
function toYmdUTC(d: Date): string {
const yy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
return `${yy}-${mm}-${dd}`;
}
export async function GET(req: Request) {
const userId = getUserIdFromRequest(req);
if (!userId) return NextResponse.json({ total: 0, currentStreak: 0, maxStreak: 0 });
// 총 출석일
const total = await prisma.attendance.count({ where: { userId } });
// 모든 출석일(UTC 자정 기준) 가져와서 streak 계산
const rows = await prisma.attendance.findMany({
where: { userId },
select: { date: true },
orderBy: { date: "asc" },
});
const days = Array.from(new Set(rows.map((r) => toYmdUTC(new Date(r.date))))); // unique, asc
// 현재 연속 출석
let currentStreak = 0;
if (days.length > 0) {
const set = new Set(days);
const now = new Date();
// 로컬 날짜(사용자 체감 날짜)를 UTC 자정으로 정규화하여 비교
let cursor = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0));
let tried = 0;
while (tried < 2 && currentStreak === 0) {
const startYmd = toYmdUTC(cursor);
if (!set.has(startYmd)) {
cursor.setUTCDate(cursor.getUTCDate() - 1);
tried += 1;
continue;
}
while (true) {
const ymd = toYmdUTC(cursor);
if (set.has(ymd)) {
currentStreak += 1;
cursor.setUTCDate(cursor.getUTCDate() - 1);
} else {
break;
}
}
}
}
// 최대 연속 출석
let maxStreak = 0;
if (days.length > 0) {
// scan through sorted list
let localMax = 1;
for (let i = 1; i < days.length; i++) {
const prev = new Date(days[i - 1] + "T00:00:00.000Z");
const cur = new Date(days[i] + "T00:00:00.000Z");
const diff = (cur.getTime() - prev.getTime()) / (24 * 60 * 60 * 1000);
if (diff === 1) {
localMax += 1;
} else if (diff > 1) {
if (localMax > maxStreak) maxStreak = localMax;
localMax = 1;
}
}
if (localMax > maxStreak) maxStreak = localMax;
}
return NextResponse.json({ total, currentStreak, maxStreak });
}

View File

@@ -0,0 +1,116 @@
export const dynamic = "force-dynamic";
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
function toYmdUTC(d: Date): string {
const yy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
return `${yy}-${mm}-${dd}`;
}
export async function GET(req: Request) {
const url = new URL(req.url);
const only = url.searchParams.get("only");
// 최대 연속 출석 상위 (Top 20, 전체 이력 기준)
async function computeStreakTop() {
// 전체 이력 기준으로 사용자별 최대 연속 출석을 계산
const rows = await prisma.attendance.findMany({
select: { userId: true, date: true },
orderBy: [{ userId: "asc" }, { date: "asc" }],
});
const maxByUser = new Map<string, number>();
let currentUserId: string | null = null;
let lastDayMs: number | null = null;
let current = 0;
let maxStreak = 0;
const commit = () => {
if (currentUserId) {
maxByUser.set(currentUserId, Math.max(maxByUser.get(currentUserId) ?? 0, maxStreak));
}
};
for (const r of rows) {
if (r.userId !== currentUserId) {
// flush previous
if (currentUserId !== null) commit();
// reset for new user
currentUserId = r.userId;
lastDayMs = null;
current = 0;
maxStreak = 0;
}
// UTC 일 단위로 비교
const ymd = toYmdUTC(new Date(r.date));
const ms = Date.parse(`${ymd}T00:00:00.000Z`);
if (lastDayMs === null) {
current = 1;
} else {
const diffDays = Math.round((ms - lastDayMs) / (24 * 60 * 60 * 1000));
if (diffDays === 1) {
current += 1;
} else if (diffDays > 0) {
current = 1;
} else {
// 동일/역순은 이례적이지만 안전하게 스킵
current = Math.max(current, 1);
}
}
if (current > maxStreak) maxStreak = current;
lastDayMs = ms;
}
// flush last
if (currentUserId !== null) commit();
const topStreak = Array.from(maxByUser.entries())
.map(([userId, streak]) => ({ userId, streak }))
.sort((a, b) => b.streak - a.streak)
.slice(0, 20);
const streakUserIds = topStreak.map((s) => s.userId);
const streakUsers = await prisma.user.findMany({
where: { userId: { in: streakUserIds } },
select: { userId: true, nickname: true, profileImage: true, grade: true },
});
const streakMeta = new Map(streakUsers.map((u) => [u.userId, u]));
const streak = topStreak.map((s) => ({
userId: s.userId,
nickname: streakMeta.get(s.userId)?.nickname ?? "회원",
streak: s.streak,
profileImage: streakMeta.get(s.userId)?.profileImage ?? null,
grade: streakMeta.get(s.userId)?.grade ?? 0,
}));
return streak;
}
if (only === "streak") {
const streak = await computeStreakTop();
return NextResponse.json({ streak });
}
// 전체 출석 누적 상위 (Top 10)
const overallGroups = await prisma.attendance.groupBy({
by: ["userId"],
_count: { date: true },
orderBy: { _count: { date: "desc" } },
take: 20,
});
const overallUserIds = overallGroups.map((g) => g.userId);
const overallUsers = await prisma.user.findMany({
where: { userId: { in: overallUserIds } },
select: { userId: true, nickname: true, profileImage: true, grade: true },
});
const userMeta = new Map(overallUsers.map((u) => [u.userId, u]));
const overall = overallGroups.map((g) => ({
userId: g.userId,
nickname: userMeta.get(g.userId)?.nickname ?? "회원",
count: g._count.date ?? 0,
profileImage: userMeta.get(g.userId)?.profileImage ?? null,
grade: userMeta.get(g.userId)?.grade ?? 0,
}));
const streak = await computeStreakTop();
return NextResponse.json({ overall, streak });
}

View File

@@ -1,16 +1,42 @@
export const dynamic = "force-dynamic";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { getUserIdFromRequest } from "@/lib/auth"; import { getUserIdFromRequest } from "@/lib/auth";
export async function GET(req: Request) { export async function GET(req: Request) {
const userId = getUserIdFromRequest(req); const userId = getUserIdFromRequest(req);
if (!userId) return NextResponse.json({ today: null, count: 0 }); if (!userId) return NextResponse.json({ today: null, count: 0, days: [] });
const url = new URL(req.url);
const year = url.searchParams.get("year");
const month = url.searchParams.get("month"); // 1-12
const start = new Date(); start.setHours(0,0,0,0); const start = new Date(); start.setHours(0,0,0,0);
const end = new Date(); end.setHours(23,59,59,999); const end = new Date(); end.setHours(23,59,59,999);
const today = await prisma.pointTransaction.findFirst({ const today = await prisma.attendance.findFirst({
where: { userId, reason: "attendance", createdAt: { gte: start, lte: end } }, where: { userId, date: { gte: start, lte: end } },
}); });
const count = await prisma.pointTransaction.count({ where: { userId, reason: "attendance" } }); const count = await prisma.attendance.count({ where: { userId } });
// 월별 출석 일자 목록
if (year && month) {
const y = parseInt(year, 10);
const m = parseInt(month, 10);
if (!isNaN(y) && !isNaN(m) && m >= 1 && m <= 12) {
const firstDay = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0, 0));
const lastDay = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999));
const records = await prisma.attendance.findMany({
where: { userId, date: { gte: firstDay, lte: lastDay } },
select: { date: true },
orderBy: { date: "asc" },
});
const days = records.map(r => {
const d = new Date(r.date);
const yy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
return `${yy}-${mm}-${dd}`;
});
return NextResponse.json({ today: !!today, count, days });
}
}
return NextResponse.json({ today: !!today, count }); return NextResponse.json({ today: !!today, count });
} }
@@ -19,9 +45,12 @@ export async function POST(req: Request) {
if (!userId) return NextResponse.json({ error: "login required" }, { status: 401 }); if (!userId) return NextResponse.json({ error: "login required" }, { status: 401 });
const start = new Date(); start.setHours(0,0,0,0); const start = new Date(); start.setHours(0,0,0,0);
const end = new Date(); end.setHours(23,59,59,999); const end = new Date(); end.setHours(23,59,59,999);
const exists = await prisma.pointTransaction.findFirst({ where: { userId, reason: "attendance", createdAt: { gte: start, lte: end } } }); const exists = await prisma.attendance.findFirst({ where: { userId, date: { gte: start, lte: end } } });
if (exists) return NextResponse.json({ ok: true, duplicated: true }); if (exists) return NextResponse.json({ ok: true, duplicated: true });
await prisma.pointTransaction.create({ data: { userId, amount: 10, reason: "attendance" } }); // normalize to UTC midnight
const now = new Date();
const normalized = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0));
await prisma.attendance.create({ data: { userId, date: normalized } });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }

View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { nameSchema } from "@/lib/validation/auth";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const nameRaw = searchParams.get("name") ?? "";
const name = nameRaw.trim();
const parsed = nameSchema.safeParse(name);
if (!parsed.success) {
const firstMsg = parsed.error.issues[0]?.message || "잘못된 닉네임 형식";
return NextResponse.json(
{ error: { fieldErrors: { name: [firstMsg] } } },
{ status: 400 }
);
}
const exists = await prisma.user.findFirst({ where: { name } });
return NextResponse.json({ available: !exists });
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { nicknameSchema } from "@/lib/validation/auth";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const nicknameRaw = searchParams.get("nickname") ?? "";
const nickname = nicknameRaw.trim();
const parsed = nicknameSchema.safeParse(nickname);
if (!parsed.success) {
const firstMsg = parsed.error.issues[0]?.message || "잘못된 아이디 형식";
return NextResponse.json(
{ error: { fieldErrors: { nickname: [firstMsg] } } },
{ status: 400 }
);
}
const exists = await prisma.user.findUnique({ where: { nickname } });
return NextResponse.json({ available: !exists });
}

View File

@@ -12,11 +12,18 @@ export async function POST(req: Request) {
const body = await req.json(); const body = await req.json();
const parsed = loginSchema.safeParse(body); const parsed = loginSchema.safeParse(body);
if (!parsed.success) if (!parsed.success)
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); return NextResponse.json(
const { nickname, password } = parsed.data; { error: "아이디 또는 비밀번호가 일치하지 않습니다" },
const user = await prisma.user.findUnique({ where: { nickname } }); { status: 401 }
);
const { id, password } = parsed.data;
// DB에서는 로그인 아이디를 nickname 컬럼으로 보관
const user = await prisma.user.findUnique({ where: { nickname: id } });
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) { if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 }); return NextResponse.json(
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
{ status: 401 }
);
} }
return NextResponse.json({ user: { userId: user.userId, nickname: user.nickname } }); return NextResponse.json({ user: { userId: user.userId, nickname: user.nickname } });
} }

View File

@@ -5,13 +5,33 @@ import { getUserIdFromRequest } from "@/lib/auth";
export async function GET(req: Request) { export async function GET(req: Request) {
const userId = getUserIdFromRequest(req); const userId = getUserIdFromRequest(req);
if (!userId) return NextResponse.json({ permissions: [] }); if (!userId) return NextResponse.json({ permissions: [] });
const roles = await prisma.userRole.findMany({ where: { userId }, select: { roleId: true } });
if (roles.length === 0) return NextResponse.json({ permissions: [] }); const user = await prisma.user.findUnique({
const roleIds = roles.map((r) => r.roleId); where: { userId },
const permissions = await prisma.rolePermission.findMany({ select: {
where: { roleId: { in: roleIds }, allowed: true }, authLevel: true,
select: { resource: true, action: true }, userRoles: { select: { roleId: true } },
},
}); });
if (!user) return NextResponse.json({ permissions: [] });
const roleIds = user.userRoles.map((r) => r.roleId);
const rolePermissions =
roleIds.length > 0
? await prisma.rolePermission.findMany({
where: { roleId: { in: roleIds }, allowed: true },
select: { resource: true, action: true },
})
: [];
const hasAdminPerm = rolePermissions.some(
(perm) => perm.resource === "ADMIN" && perm.action === "ADMINISTER"
);
const permissions =
user.authLevel === "ADMIN" && !hasAdminPerm
? [{ resource: "ADMIN" as const, action: "ADMINISTER" as const }, ...rolePermissions]
: rolePermissions;
return NextResponse.json({ permissions }); return NextResponse.json({ permissions });
} }

View File

@@ -8,17 +8,41 @@ export async function POST(req: Request) {
const parsed = registerSchema.safeParse(body); const parsed = registerSchema.safeParse(body);
if (!parsed.success) if (!parsed.success)
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const { nickname, name, phone, birth, password } = parsed.data; const { nickname, name, password, profileImage } = parsed.data as {
const exists = await prisma.user.findFirst({ where: { OR: [{ nickname }, { phone }] } }); nickname: string;
if (exists) return NextResponse.json({ error: "이미 존재하는 사용자" }, { status: 409 }); name: string;
password: string;
profileImage?: string;
};
// 아이디(닉네임 필드와 구분)를 우선 검사
const nicknameExists = await prisma.user.findFirst({ where: { nickname } });
if (nicknameExists) {
return NextResponse.json(
{ error: { fieldErrors: { nickname: ["이미 사용 중인 아이디입니다"] } } },
{ status: 409 }
);
}
// 표시용 닉네임(name)도 유일해야 함
const nameExists = await prisma.user.findFirst({ where: { name } });
if (nameExists) {
return NextResponse.json(
{ error: { fieldErrors: { name: ["이미 사용 중인 닉네임입니다"] } } },
{ status: 409 }
);
}
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
userId: nickname,
nickname, nickname,
name, name,
phone,
birth: new Date(birth),
passwordHash: hashPassword(password), passwordHash: hashPassword(password),
agreementTermsAt: new Date(), agreementTermsAt: new Date(),
// 일부 환경에서 birth 컬럼이 NOT NULL 제약으로 남아있는 경우가 있어 안전 기본값을 기록
birth: new Date(0),
// 일부 환경에서 phone 컬럼이 NOT NULL+UNIQUE 제약으로 남아있는 경우가 있어
// 임시 유니크 플레이스홀더를 기록합니다. (후속 마이그레이션으로 NULL 허용 권장)
phone: `ph_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
profileImage: profileImage || null,
}, },
select: { userId: true, nickname: true }, select: { userId: true, nickname: true },
}); });

View File

@@ -29,16 +29,59 @@ export async function POST(req: Request) {
const body = await req.json(); const body = await req.json();
const parsed = loginSchema.safeParse(body); const parsed = loginSchema.safeParse(body);
if (!parsed.success) if (!parsed.success)
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); return NextResponse.json(
const { nickname, password } = parsed.data; { error: "아이디 또는 비밀번호가 일치하지 않습니다" },
const user = await prisma.user.findUnique({ where: { nickname } }); { status: 401 }
);
const { id, password } = parsed.data;
// DB에서는 로그인 아이디를 nickname 컬럼으로 보관
const user = await prisma.user.findUnique({ where: { nickname: id } });
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) { if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 }); return NextResponse.json(
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
{ status: 401 }
);
} }
// 사용자의 관리자 권한 여부 확인
let isAdmin = false;
const userRoles = await prisma.userRole.findMany({
where: { userId: user.userId },
select: { roleId: true },
});
if (userRoles.length > 0) {
const roleIds = userRoles.map((r) => r.roleId);
const hasAdmin = await prisma.rolePermission.findFirst({
where: {
roleId: { in: roleIds },
resource: "ADMIN",
action: "ADMINISTER",
allowed: true,
},
select: { id: true },
});
isAdmin = !!hasAdmin;
}
// 추가 안전장치: 사용자 레코드의 authLevel이 ADMIN이면 관리자 취급
if (!isAdmin && user.authLevel === "ADMIN") {
isAdmin = true;
}
const res = NextResponse.json({ ok: true, user: { userId: user.userId, nickname: user.nickname } }); const res = NextResponse.json({ ok: true, user: { userId: user.userId, nickname: user.nickname } });
// HTTPS 요청에서만 Secure 속성 부여 (HTTP 환경에서는 생략하여 로컬 start에서도 동작)
let secureAttr = "";
try {
const isHttps = new URL(req.url).protocol === "https:";
secureAttr = isHttps ? "; Secure" : "";
} catch {
secureAttr = "";
}
res.headers.append( res.headers.append(
"Set-Cookie", "Set-Cookie",
`uid=${encodeURIComponent(user.userId)}; Path=/; HttpOnly; SameSite=Lax` `uid=${encodeURIComponent(user.userId)}; Path=/; HttpOnly; SameSite=Lax${secureAttr}`
);
res.headers.append(
"Set-Cookie",
`isAdmin=${isAdmin ? "1" : "0"}; Path=/; HttpOnly; SameSite=Lax${secureAttr}`
); );
return res; return res;
} }
@@ -46,6 +89,7 @@ export async function POST(req: Request) {
export async function DELETE() { export async function DELETE() {
const res = NextResponse.json({ ok: true }); const res = NextResponse.json({ ok: true });
res.headers.append("Set-Cookie", `uid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`); res.headers.append("Set-Cookie", `uid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`);
res.headers.append("Set-Cookie", `isAdmin=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`);
return res; return res;
} }

View File

@@ -4,7 +4,7 @@ import prisma from "@/lib/prisma";
export async function GET(req: Request) { export async function GET(req: Request) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const category = searchParams.get("category"); // slug or id const category = searchParams.get("category"); // slug or id
const where: any = {}; const where: any = { status: "active" };
if (category) { if (category) {
if (category.length === 25 || category.length === 24) { if (category.length === 25 || category.length === 24) {
where.categoryId = category; where.categoryId = category;
@@ -20,7 +20,6 @@ export async function GET(req: Request) {
name: true, name: true,
slug: true, slug: true,
description: true, description: true,
requiresApproval: true,
allowAnonymousPost: true, allowAnonymousPost: true,
isAdultOnly: true, isAdultOnly: true,
category: { select: { id: true, name: true, slug: true } }, category: { select: { id: true, name: true, slug: true } },

View File

@@ -10,7 +10,7 @@ export async function GET() {
boards: { boards: {
where: { status: "active" }, where: { status: "active" },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: { id: true, name: true, slug: true, requiresApproval: true }, select: { id: true, name: true, slug: true },
}, },
}, },
}); });

View File

@@ -2,9 +2,11 @@ import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { z } from "zod"; import { z } from "zod";
import { hashPassword } from "@/lib/password"; import { hashPassword } from "@/lib/password";
import { getUserIdOrAdmin } from "@/lib/auth";
const createCommentSchema = z.object({ const createCommentSchema = z.object({
postId: z.string().min(1), postId: z.string().min(1),
parentId: z.string().nullable().optional(), // 부모 댓글 ID (대댓글). 최상위 댓글은 null 허용
authorId: z.string().optional(), authorId: z.string().optional(),
content: z.string().min(1), content: z.string().min(1),
isAnonymous: z.boolean().optional(), isAnonymous: z.boolean().optional(),
@@ -18,17 +20,51 @@ export async function POST(req: Request) {
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
} }
const { postId, authorId, content, isAnonymous, isSecret, secretPassword } = parsed.data; const { postId, parentId, authorId, content, isAnonymous, isSecret, secretPassword } = parsed.data;
// 개발 편의: 인증 미설정 시 admin 계정으로 댓글 작성 처리
const effectiveAuthorId = authorId ?? (await getUserIdOrAdmin(req));
// 대댓글인 경우 부모 댓글 확인 및 depth 계산
let depth = 0;
if (parentId) {
const parent = await prisma.comment.findUnique({
where: { id: parentId },
select: { depth: true, postId: true },
});
if (!parent) {
return NextResponse.json({ error: "부모 댓글을 찾을 수 없습니다." }, { status: 404 });
}
if (parent.postId !== postId) {
return NextResponse.json({ error: "게시글이 일치하지 않습니다." }, { status: 400 });
}
depth = parent.depth + 1;
if (depth > 2) {
return NextResponse.json({ error: "최대 3단계까지 대댓글을 작성할 수 있습니다." }, { status: 400 });
}
}
const secretPasswordHash = secretPassword ? hashPassword(secretPassword) : null; const secretPasswordHash = secretPassword ? hashPassword(secretPassword) : null;
const comment = await prisma.comment.create({ const comment = await prisma.comment.create({
data: { data: {
postId, postId,
authorId: authorId ?? null, parentId: parentId ?? null,
depth,
authorId: effectiveAuthorId ?? null,
content, content,
isAnonymous: !!isAnonymous, isAnonymous: !!isAnonymous,
isSecret: !!isSecret, isSecret: !!isSecret,
secretPasswordHash, secretPasswordHash,
}, },
include: {
author: { select: { userId: true, nickname: true, profileImage: true } },
},
});
// 통계 업데이트: 댓글 수 증가
await prisma.postStat.upsert({
where: { postId },
update: { commentsCount: { increment: 1 } },
create: { postId, commentsCount: 1 },
}); });
return NextResponse.json({ comment }, { status: 201 }); return NextResponse.json({ comment }, { status: 201 });
} }

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { z } from "zod"; import { z } from "zod";
import type { Prisma } from "@prisma/client";
export async function GET() { export async function GET() {
const now = new Date(); const now = new Date();
@@ -30,7 +31,7 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body); const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const data = await prisma.coupon.create({ data: parsed.data }); const data = await prisma.coupon.create({ data: parsed.data as Prisma.CouponCreateInput });
return NextResponse.json({ coupon: data }, { status: 201 }); return NextResponse.json({ coupon: data }, { status: 201 });
} }

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { getUserIdOrAdmin } from "@/lib/auth";
import { verifyPassword, hashPassword } from "@/lib/password";
export async function PUT(req: Request) {
const userId = await getUserIdOrAdmin(req);
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const body = await req.json();
const currentPassword: string | undefined = body?.currentPassword;
const newPassword: string | undefined = body?.newPassword;
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: "currentPassword and newPassword required" }, { status: 400 });
}
if (newPassword.length < 8 || newPassword.length > 100) {
return NextResponse.json({ error: "password length invalid" }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { userId },
select: { passwordHash: true },
});
if (!user || !user.passwordHash) {
return NextResponse.json({ error: "invalid user" }, { status: 400 });
}
if (!verifyPassword(currentPassword, user.passwordHash)) {
return NextResponse.json({ error: "현재 비밀번호가 올바르지 않습니다" }, { status: 400 });
}
if (verifyPassword(newPassword, user.passwordHash)) {
// 새 비밀번호가 기존과 동일
return NextResponse.json({ error: "새 비밀번호가 기존과 동일합니다" }, { status: 400 });
}
await prisma.user.update({
where: { userId },
data: { passwordHash: hashPassword(newPassword) },
});
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Bad Request" }, { status: 400 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { getUserIdOrAdmin } from "@/lib/auth";
export async function PUT(req: Request) {
const userId = await getUserIdOrAdmin(req);
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const body = await req.json();
const url: string | null = body?.url ?? null;
if (url !== null && typeof url !== "string") {
return NextResponse.json({ error: "invalid url" }, { status: 400 });
}
// 간단 검증: 내부 업로드 경로 또는 http(s) 허용
if (url) {
const ok = url.startsWith("/uploads/") || url.startsWith("http://") || url.startsWith("https://");
if (!ok) return NextResponse.json({ error: "invalid url" }, { status: 400 });
if (url.length > 1000) return NextResponse.json({ error: "url too long" }, { status: 400 });
}
const user = await prisma.user.update({
where: { userId },
data: { profileImage: url || null },
select: { userId: true, profileImage: true },
});
return NextResponse.json({ ok: true, user });
} catch {
return NextResponse.json({ error: "Bad Request" }, { status: 400 });
}
}

22
src/app/api/me/route.ts Normal file
View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { getUserIdOrAdmin } from "@/lib/auth";
export async function GET(req: Request) {
const userId = await getUserIdOrAdmin(req);
if (!userId) return NextResponse.json({ user: null });
const user = await prisma.user.findUnique({
where: { userId },
select: {
userId: true,
nickname: true,
profileImage: true,
points: true,
level: true,
grade: true,
},
});
return NextResponse.json({ user });
}

View File

@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
import { getUserIdFromRequest } from "@/lib/auth";
const sendSchema = z.object({
receiverId: z.string().min(1, "수신자 정보가 없습니다"),
body: z.string().min(1, "메시지를 입력하세요").max(2000, "메시지는 2000자 이하여야 합니다"),
});
export async function POST(req: Request) {
const senderId = getUserIdFromRequest(req);
if (!senderId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const contentType = req.headers.get("content-type") || "";
let data: any = {};
if (contentType.includes("application/json")) {
data = await req.json().catch(() => ({}));
} else if (contentType.includes("application/x-www-form-urlencoded")) {
const form = await req.formData();
data = Object.fromEntries(form.entries());
} else {
// 기본적으로 form 으로 간주
const form = await req.formData().catch(() => null);
if (form) data = Object.fromEntries(form.entries());
}
const parsed = sendSchema.safeParse(data);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const { receiverId, body } = parsed.data;
if (receiverId === senderId) {
return NextResponse.json({ error: "자기 자신에게는 보낼 수 없습니다" }, { status: 400 });
}
const receiver = await prisma.user.findUnique({ where: { userId: receiverId }, select: { userId: true } });
if (!receiver) return NextResponse.json({ error: "수신자를 찾을 수 없습니다" }, { status: 404 });
const message = await prisma.message.create({
data: { senderId, receiverId, body },
select: { id: true, senderId: true, receiverId: true, body: true, createdAt: true },
});
return NextResponse.json({ message }, { status: 201 });
}
const listQuery = z.object({
box: z.enum(["received", "sent"]).default("received").optional(),
page: z.coerce.number().min(1).default(1).optional(),
pageSize: z.coerce.number().min(1).max(100).default(20).optional(),
});
export async function GET(req: Request) {
const userId = getUserIdFromRequest(req);
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(req.url);
const parsed = listQuery.safeParse(Object.fromEntries(searchParams));
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const { box = "received", page = 1, pageSize = 20 } = parsed.data;
const where =
box === "received"
? { receiverId: userId }
: { senderId: userId };
const [total, items] = await Promise.all([
prisma.message.count({ where }),
prisma.message.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
body: true,
createdAt: true,
sender: { select: { userId: true, nickname: true } },
receiver: { select: { userId: true, nickname: true } },
},
}),
]);
return NextResponse.json({ total, page, pageSize, items });
}

View File

@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function GET() {
const categories = await prisma.partnerCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { name: "asc" }] });
return NextResponse.json({ categories });
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { z } from "zod"; import { z } from "zod";
import type { Prisma } from "@prisma/client";
const schema = z.object({ name: z.string().min(1), contact: z.string().min(1), category: z.string().optional(), message: z.string().min(1) }); const schema = z.object({ name: z.string().min(1), contact: z.string().min(1), category: z.string().optional(), message: z.string().min(1) });
@@ -8,7 +9,7 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const parsed = schema.safeParse(body); const parsed = schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const created = await prisma.partnerInquiry.create({ data: parsed.data }); const created = await prisma.partnerInquiry.create({ data: parsed.data as Prisma.PartnerInquiryCreateInput });
return NextResponse.json({ inquiry: created }, { status: 201 }); return NextResponse.json({ inquiry: created }, { status: 201 });
} }

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { z } from "zod"; import { z } from "zod";
import type { Prisma } from "@prisma/client";
const schema = z.object({ name: z.string().min(1), category: z.string().min(1), latitude: z.coerce.number(), longitude: z.coerce.number(), address: z.string().optional(), contact: z.string().optional() }); const schema = z.object({ name: z.string().min(1), category: z.string().min(1), latitude: z.coerce.number(), longitude: z.coerce.number(), address: z.string().optional(), contact: z.string().optional() });
@@ -8,7 +9,7 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const parsed = schema.safeParse(body); const parsed = schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const created = await prisma.partnerRequest.create({ data: parsed.data }); const created = await prisma.partnerRequest.create({ data: parsed.data as Prisma.PartnerRequestCreateInput });
return NextResponse.json({ request: created }, { status: 201 }); return NextResponse.json({ request: created }, { status: 201 });
} }

View File

@@ -2,10 +2,13 @@ import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
export async function GET() { export async function GET() {
const items = await prisma.partnerShop.findMany({ // 통합: 승인된 파트너 요청을 메인 노출용으로 사용
where: { active: true }, const rows = await prisma.partnerRequest.findMany({
where: { status: "approved", active: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
select: { id: true, region: true, name: true, address: true, imageUrl: true, sortOrder: true, active: true, category: true },
}); });
const items = rows.map((r) => ({ id: r.id, region: r.region || "", name: r.name, address: r.address || "", imageUrl: r.imageUrl || "", sortOrder: r.sortOrder, active: r.active, category: r.category }));
return NextResponse.json({ items }); return NextResponse.json({ items });
} }

View File

@@ -1,29 +1,24 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
function haversine(lat1: number, lon1: number, lat2: number, lon2: number) {
const toRad = (v: number) => (v * Math.PI) / 180;
const R = 6371; // km
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
export async function GET(req: Request) { export async function GET(req: Request) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const lat = Number(searchParams.get("lat"));
const lon = Number(searchParams.get("lon"));
const category = searchParams.get("category") || undefined; const category = searchParams.get("category") || undefined;
const radius = Number(searchParams.get("radius")) || 10; // km const categoryId = searchParams.get("categoryId") || undefined;
const where = category ? { category } : {}; let where: any = {};
if (categoryId) {
// 카테고리 ID 매칭 + 과거 데이터 호환(문자열 category명 매칭)
let catName: string | undefined;
try {
const cat = await prisma.partnerCategory.findUnique({ where: { id: categoryId }, select: { name: true } });
catName = cat?.name;
} catch {}
where = catName ? { OR: [{ categoryId }, { category: catName }] } : { categoryId };
} else if (category) {
where = { category };
}
const partners = await prisma.partner.findMany({ where, orderBy: { createdAt: "desc" } }); const partners = await prisma.partner.findMany({ where, orderBy: { createdAt: "desc" } });
const withDistance = isFinite(lat) && isFinite(lon) return NextResponse.json({ partners });
? partners.map((p) => ({ ...p, distance: haversine(lat, lon, p.latitude, p.longitude) })).filter((p) => p.distance <= radius)
: partners.map((p) => ({ ...p, distance: null }));
withDistance.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0));
return NextResponse.json({ partners: withDistance });
} }

View File

@@ -1,28 +1,70 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { getUserIdFromRequest } from "@/lib/auth";
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) { export async function GET(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params; const { id } = await context.params;
const comments = await prisma.comment.findMany({ const requesterId = getUserIdFromRequest(req);
where: { postId: id }, const post = await prisma.post.findUnique({
where: { id },
select: { authorId: true },
});
const postAuthorId = post?.authorId ?? null;
// 최상위 댓글만 가져오기
const topComments = await prisma.comment.findMany({
where: {
postId: id,
parentId: null, // 최상위 댓글만
},
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
select: { include: {
id: true, author: { select: { userId: true, nickname: true, profileImage: true } },
content: true, replies: {
isAnonymous: true, include: {
isSecret: true, author: { select: { userId: true, nickname: true, profileImage: true } },
secretPasswordHash: true, replies: {
createdAt: true, include: {
author: { select: { userId: true, nickname: true, profileImage: true } },
},
orderBy: { createdAt: "asc" },
},
},
orderBy: { createdAt: "asc" },
},
}, },
}); });
const presented = comments.map((c) => ({
id: c.id, // 재귀적으로 댓글 구조 변환
content: c.isSecret ? "비밀댓글입니다." : c.content, const transformComment = (comment: any): any => {
isAnonymous: c.isAnonymous, const commentAuthorId: string | null = comment.author?.userId ?? null;
isSecret: c.isSecret, const canViewSecret =
anonId: c.isAnonymous ? c.id.slice(-6) : undefined, !comment.isSecret ||
createdAt: c.createdAt, (requesterId != null &&
})); (requesterId === commentAuthorId || requesterId === postAuthorId));
return {
id: comment.id,
parentId: comment.parentId,
depth: comment.depth,
content: canViewSecret ? comment.content : "비밀댓글입니다.",
isAnonymous: comment.isAnonymous,
isSecret: comment.isSecret,
author: comment.author
? {
userId: comment.author.userId,
nickname: comment.author.nickname,
profileImage: comment.author.profileImage,
}
: null,
anonId: comment.isAnonymous ? comment.id.slice(-6) : undefined,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
replies: comment.replies ? comment.replies.map(transformComment) : [],
};
};
const presented = topComments.map(transformComment);
return NextResponse.json({ comments: presented }); return NextResponse.json({ comments: presented });
} }

View File

@@ -1,18 +1,56 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { z } from "zod"; import { getUserIdFromRequest } from "@/lib/auth";
import crypto from "crypto";
const schema = z.object({ userId: z.string().optional(), clientHash: z.string().optional() }).refine(
(d) => !!d.userId || !!d.clientHash,
{ message: "Provide userId or clientHash" }
);
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) { export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params; const { id } = await context.params;
const body = await req.json().catch(() => ({})); // 1) 사용자 식별 시도: 쿠키/헤더에서 userId 우선
const parsed = schema.safeParse(body); let userId = getUserIdFromRequest(req);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const { userId, clientHash } = parsed.data; // 2) 바디에서 clientHash 수용(클라이언트가 보낼 수 있음)
let clientHash: string | null = null;
// JSON 우선
const jsonBody = await req
.json()
.catch(() => null);
if (jsonBody && typeof jsonBody === "object") {
if (!userId && typeof jsonBody.userId === "string" && jsonBody.userId.length > 0) {
userId = jsonBody.userId;
}
if (typeof jsonBody.clientHash === "string" && jsonBody.clientHash.length > 0) {
clientHash = jsonBody.clientHash;
}
} else {
// form 제출도 허용 (빈 폼일 수 있음)
const form = await req
.formData()
.catch(() => null);
if (form) {
const formUserId = form.get("userId");
const formClientHash = form.get("clientHash");
if (!userId && typeof formUserId === "string" && formUserId.length > 0) {
userId = formUserId;
}
if (typeof formClientHash === "string" && formClientHash.length > 0) {
clientHash = formClientHash;
}
}
}
// 3) 둘 다 없으면 서버에서 익명 고유 clientHash 생성(IP + UA 기반)
if (!userId && !clientHash) {
const ua = req.headers.get("user-agent") || "";
// x-forwarded-for 첫번째가 원 IP 가정
const ip =
(req.headers.get("x-forwarded-for") || "")
.split(",")
.map((s) => s.trim())[0] ||
req.headers.get("x-real-ip") ||
"";
const raw = `${ip}::${ua}`;
clientHash = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 32);
}
const existing = await prisma.reaction.findFirst({ const existing = await prisma.reaction.findFirst({
where: { postId: id, type: "RECOMMEND", userId: userId ?? null, clientHash: clientHash ?? null }, where: { postId: id, type: "RECOMMEND", userId: userId ?? null, clientHash: clientHash ?? null },
@@ -27,6 +65,19 @@ export async function POST(req: Request, context: { params: Promise<{ id: string
}); });
} }
// 폼 제출의 경우, JSON 대신 원래 페이지로 리다이렉트
// jsonBody가 없고(formData 경로였거나 파싱 실패), 브라우저에서 온 요청으로 가정
if (!jsonBody) {
const referer = req.headers.get("referer");
if (referer) {
return NextResponse.redirect(referer);
}
const baseUrl = new URL(req.url);
baseUrl.pathname = `/posts/${id}`;
baseUrl.search = "";
return NextResponse.redirect(baseUrl.toString());
}
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }

View File

@@ -9,7 +9,9 @@ export async function GET(_: Request, context: { params: Promise<{ id: string }>
const post = await prisma.post.findUnique({ const post = await prisma.post.findUnique({
where: { id }, where: { id },
include: { include: {
author: { select: { userId: true, nickname: true } },
board: { select: { id: true, name: true, slug: true } }, board: { select: { id: true, name: true, slug: true } },
stat: { select: { views: true, recommendCount: true, commentsCount: true } },
}, },
}); });
if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 }); if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 });

View File

@@ -5,11 +5,81 @@ import { getUserIdFromRequest } from "@/lib/auth";
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) { export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params; const { id } = await context.params;
const userId = getUserIdFromRequest(req); const userId = getUserIdFromRequest(req);
const ip = req.headers.get("x-forwarded-for") || undefined; // x-forwarded-for는 다중 IP가 올 수 있음 -> 첫 번째(실제 클라이언트)만 사용
const forwardedFor = req.headers.get("x-forwarded-for") || undefined;
const ip = forwardedFor ? forwardedFor.split(",")[0]?.trim() || undefined : undefined;
const userAgent = req.headers.get("user-agent") || undefined; const userAgent = req.headers.get("user-agent") || undefined;
await prisma.postViewLog.create({ data: { postId: id, userId: userId ?? null, ip, userAgent } });
await prisma.postStat.upsert({ where: { postId: id }, update: { views: { increment: 1 } }, create: { postId: id, views: 1 } }); // 중복 방지: 로그인 사용자는 userId로, 비로그인은 (ip + userAgent) 조합으로 1회만 카운트
return NextResponse.json({ ok: true }); const orConditions: any[] = [];
if (userId) {
orConditions.push({ userId });
}
// 비로그인 식별: 가능한 한 많은 신호를 사용 (ip+UA 우선, 단일 ip 또는 단일 UA로도 보수적으로 차단)
if (!userId) {
if (ip && userAgent) {
orConditions.push({ userId: null, ip, userAgent });
} else if (ip) {
orConditions.push({ userId: null, ip, userAgent: null });
} else if (userAgent) {
orConditions.push({ userId: null, ip: null, userAgent });
}
}
let counted = false;
await prisma.$transaction(async (tx) => {
if (orConditions.length > 0) {
const exists = await tx.postViewLog.findFirst({
where: {
postId: id,
OR: orConditions,
},
select: { id: true },
});
if (exists) {
return;
}
} else {
// 식별 정보가 전혀 없을 때는 안전을 위해 카운트하지 않음
return;
}
await tx.postViewLog.create({
data: {
postId: id,
userId: userId ?? null,
ip: ip ?? null,
userAgent: userAgent ?? null,
},
});
await tx.postStat.upsert({
where: { postId: id },
update: { views: { increment: 1 } },
create: { postId: id, views: 1 },
});
// 일일 조회수 업데이트 (오늘 날짜)
const today = new Date();
today.setHours(0, 0, 0, 0);
await tx.dailyPostView.upsert({
where: {
postId_date: {
postId: id,
date: today,
},
},
update: { viewCount: { increment: 1 } },
create: {
postId: id,
date: today,
viewCount: 1,
},
});
counted = true;
});
return NextResponse.json({ ok: true, counted });
} }

View File

@@ -0,0 +1,92 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const boardId = searchParams.get("boardId");
const period = searchParams.get("period") || "daily"; // daily | weekly
// 날짜 범위 계산
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - 7);
startOfWeek.setHours(0, 0, 0, 0);
const dateFilter = period === "daily"
? {
date: {
gte: startOfToday,
lte: endOfToday,
}
}
: { date: { gte: startOfWeek } };
// 일일 조회수 테이블에서 조회수 합계 계산
const dailyViews = await prisma.dailyPostView.groupBy({
by: ["postId"],
where: dateFilter,
_sum: {
viewCount: true,
},
orderBy: {
_sum: {
viewCount: "desc",
},
},
take: 20, // 조회수 상위 20개만 가져와서 게시글 정보 조회
});
if (dailyViews.length === 0) {
return NextResponse.json({ items: [], period });
}
const postIds = dailyViews.map((dv) => dv.postId);
// 게시글 정보 조회
const posts = await prisma.post.findMany({
where: {
id: { in: postIds },
status: "published",
...(boardId ? { boardId } : {}),
},
select: {
id: true,
title: true,
createdAt: true,
boardId: true,
board: { select: { id: true, name: true, slug: true } },
isPinned: true,
status: true,
author: { select: { userId: true, nickname: true } },
stat: { select: { recommendCount: true, views: true, commentsCount: true } },
postTags: { select: { tag: { select: { name: true, slug: true } } } },
},
});
// 조회수와 게시글 매핑
const viewCountMap = new Map(
dailyViews.map((dv) => [dv.postId, dv._sum.viewCount ?? 0])
);
// 조회수 순으로 정렬 (조회수가 0보다 큰 것만)
const postsWithViews = posts
.map((post) => ({
...post,
viewCount: viewCountMap.get(post.id) ?? 0,
}))
.filter((post) => post.viewCount > 0) // 조회수가 0보다 큰 것만
.sort((a, b) => {
// 고정글 우선
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
// 조회수 순
if (b.viewCount !== a.viewCount) return b.viewCount - a.viewCount;
// 최신순
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
})
.slice(0, 5); // 상위 5개만
return NextResponse.json({ items: postsWithViews, period });
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { z } from "zod"; import { z } from "zod";
import { getUserIdOrAdmin } from "@/lib/auth";
const createPostSchema = z.object({ const createPostSchema = z.object({
boardId: z.string().min(1), boardId: z.string().min(1),
@@ -17,8 +18,9 @@ export async function POST(req: Request) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
} }
const { boardId, authorId, title, content, isAnonymous } = parsed.data; const { boardId, authorId, title, content, isAnonymous } = parsed.data;
// 개발 편의를 위해, 인증 정보가 없으면 admin 사용자로 대체
const effectiveAuthorId = authorId ?? (await getUserIdOrAdmin(req));
const board = await prisma.board.findUnique({ where: { id: boardId } }); const board = await prisma.board.findUnique({ where: { id: boardId } });
const requiresApproval = board?.requiresApproval ?? false;
// 사진형 보드 필수 이미지 검증: content 내 이미지 링크 최소 1개 // 사진형 보드 필수 이미지 검증: content 내 이미지 링크 최소 1개
const isImageOnly = (board?.requiredFields as any)?.imageOnly; const isImageOnly = (board?.requiredFields as any)?.imageOnly;
const minImages = (board?.requiredFields as any)?.minImages ?? 0; const minImages = (board?.requiredFields as any)?.minImages ?? 0;
@@ -31,11 +33,11 @@ export async function POST(req: Request) {
const post = await prisma.post.create({ const post = await prisma.post.create({
data: { data: {
boardId, boardId,
authorId: authorId ?? null, authorId: effectiveAuthorId ?? null,
title, title,
content, content,
isAnonymous: !!isAnonymous, isAnonymous: !!isAnonymous,
status: requiresApproval ? "hidden" : "published", status: "published",
}, },
}); });
return NextResponse.json({ post }, { status: 201 }); return NextResponse.json({ post }, { status: 201 });
@@ -46,9 +48,10 @@ const listQuerySchema = z.object({
pageSize: z.coerce.number().min(1).max(100).default(10), pageSize: z.coerce.number().min(1).max(100).default(10),
boardId: z.string().optional(), boardId: z.string().optional(),
q: z.string().optional(), q: z.string().optional(),
sort: z.enum(["recent", "popular"]).default("recent").optional(), sort: z.enum(["recent", "popular", "views", "likes", "comments"]).default("recent").optional(),
tag: z.string().optional(), // Tag.slug tag: z.string().optional(), // Tag.slug
author: z.string().optional(), // User.nickname contains author: z.string().optional(), // User.nickname contains
authorId: z.string().optional(), // User.userId exact match
start: z.coerce.date().optional(), // createdAt >= start start: z.coerce.date().optional(), // createdAt >= start
end: z.coerce.date().optional(), // createdAt <= end end: z.coerce.date().optional(), // createdAt <= end
}); });
@@ -59,7 +62,7 @@ export async function GET(req: Request) {
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
} }
const { page, pageSize, boardId, q, sort = "recent", tag, author, start, end } = parsed.data; const { page, pageSize, boardId, q, sort = "recent", tag, author, authorId, start, end } = parsed.data;
const where = { const where = {
NOT: { status: "deleted" as const }, NOT: { status: "deleted" as const },
...(boardId ? { boardId } : {}), ...(boardId ? { boardId } : {}),
@@ -76,7 +79,11 @@ export async function GET(req: Request) {
postTags: { some: { tag: { slug: tag } } }, postTags: { some: { tag: { slug: tag } } },
} }
: {}), : {}),
...(author ...(authorId
? {
authorId,
}
: author
? { ? {
author: { nickname: { contains: author } }, author: { nickname: { contains: author } },
} }
@@ -96,8 +103,12 @@ export async function GET(req: Request) {
prisma.post.findMany({ prisma.post.findMany({
where, where,
orderBy: orderBy:
sort === "popular" sort === "popular" || sort === "likes"
? [{ isPinned: "desc" }, { stat: { recommendCount: "desc" } }, { createdAt: "desc" }] ? [{ isPinned: "desc" }, { stat: { recommendCount: "desc" } }, { createdAt: "desc" }]
: sort === "views"
? [{ isPinned: "desc" }, { stat: { views: "desc" } }, { createdAt: "desc" }]
: sort === "comments"
? [{ isPinned: "desc" }, { stat: { commentsCount: "desc" } }, { createdAt: "desc" }]
: [{ isPinned: "desc" }, { createdAt: "desc" }], : [{ isPinned: "desc" }, { createdAt: "desc" }],
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
@@ -106,9 +117,10 @@ export async function GET(req: Request) {
title: true, title: true,
createdAt: true, createdAt: true,
boardId: true, boardId: true,
board: { select: { id: true, name: true, slug: true } },
isPinned: true, isPinned: true,
status: true, status: true,
author: { select: { nickname: true } }, author: { select: { userId: true, nickname: true } },
stat: { select: { recommendCount: true, views: true, commentsCount: true } }, stat: { select: { recommendCount: true, views: true, commentsCount: true } },
postTags: { select: { tag: { select: { name: true, slug: true } } } }, postTags: { select: { tag: { select: { name: true, slug: true } } } },
}, },

View File

@@ -1,86 +1,153 @@
import { PostList } from "@/app/components/PostList"; import { PostList } from "@/app/components/PostList";
import { HeroBanner } from "@/app/components/HeroBanner"; import { HeroBanner } from "@/app/components/HeroBanner";
import Link from "next/link";
import { BoardToolbar } from "@/app/components/BoardToolbar"; import { BoardToolbar } from "@/app/components/BoardToolbar";
import { headers } from "next/headers"; import { headers } from "next/headers";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { UserAvatar } from "@/app/components/UserAvatar";
import { RankIcon1st } from "@/app/components/RankIcon1st";
import { RankIcon2nd } from "@/app/components/RankIcon2nd";
import { RankIcon3rd } from "@/app/components/RankIcon3rd";
import { GradeIcon } from "@/app/components/GradeIcon";
import AttendanceCalendar from "@/app/components/AttendanceCalendar";
// Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다. // Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다.
export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) { export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) {
const p = params?.then ? await params : params; const p = params?.then ? await params : params;
const sp = searchParams?.then ? await searchParams : searchParams; const sp = searchParams?.then ? await searchParams : searchParams;
const id = p.id as string; const idOrSlug = p.id as string;
const sort = (sp?.sort as "recent" | "popular" | undefined) ?? "recent"; const sort = (sp?.sort as "recent" | "popular" | "views" | "likes" | "comments" | undefined) ?? "recent";
const period = (sp?.period as string | undefined) ?? "monthly";
// 보드 slug 조회 (새 글 페이지 프리셋 전달) // 보드 slug 조회 (새 글 페이지 프리셋 전달)
const h = await headers(); const h = await headers();
const host = h.get("host") ?? "localhost:3000"; const host = h.get("host") ?? "localhost:3000";
const proto = h.get("x-forwarded-proto") ?? "http"; const proto = h.get("x-forwarded-proto") ?? "http";
const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`; const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`;
// 로그인 여부 파악
const cookieHeader = h.get("cookie") || "";
const uid = cookieHeader
.split(";")
.map((s) => s.trim())
.find((pair) => pair.startsWith("uid="))
?.split("=")[1];
const isLoggedIn = !!uid;
const res = await fetch(new URL("/api/boards", base).toString(), { cache: "no-store" }); const res = await fetch(new URL("/api/boards", base).toString(), { cache: "no-store" });
const { boards } = await res.json(); const { boards } = await res.json();
const board = (boards || []).find((b: any) => b.id === id); const board = (boards || []).find((b: any) => b.slug === idOrSlug || b.id === idOrSlug);
const id = board?.id as string;
const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id); const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id);
const categoryName = board?.category?.name ?? ""; const categoryName = board?.category?.name ?? "";
// 메인배너 표시 설정
const SETTINGS_KEY = "mainpage_settings" as const;
const settingRow = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
const parsed = settingRow ? JSON.parse(settingRow.value as string) : {};
const showBanner: boolean = parsed.showBanner ?? true;
// 리스트 뷰 타입 확인 (특수랭킹일 경우 게시글 대신 랭킹 노출) // 리스트 뷰 타입 확인 (특수랭킹/출석부 등)
const boardView = await prisma.board.findUnique({ const boardView = await prisma.board.findUnique({
where: { id }, where: { id },
select: { listViewType: { select: { key: true } } }, select: { listViewType: { select: { key: true } } },
}); });
const isSpecialRanking = boardView?.listViewType?.key === "list_special_rank"; const isSpecialRanking = boardView?.listViewType?.key === "list_special_rank";
const isAttendance = boardView?.listViewType?.key === "list_special_attendance";
let rankingItems: { userId: string; nickname: string; points: number }[] = []; let rankingItems: { userId: string; nickname: string; points: number; profileImage: string | null; grade: number }[] = [];
if (isSpecialRanking) { if (isSpecialRanking) {
const rankingUrl = new URL(`/api/ranking?period=${encodeURIComponent(period)}`, base).toString(); const topUsers = await prisma.user.findMany({
const rankingRes = await fetch(rankingUrl, { cache: "no-store" }); select: { userId: true, nickname: true, points: true, profileImage: true, grade: true },
const rankingData = await rankingRes.json().catch(() => ({ items: [] })); where: { status: "active" },
rankingItems = rankingData?.items ?? []; orderBy: { points: "desc" },
take: 100,
});
rankingItems = topUsers.map((u) => ({ userId: u.userId, nickname: u.nickname, points: u.points, profileImage: u.profileImage, grade: u.grade }));
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 상단 배너 (서브카테고리 표시) */} {/* 상단 배너 (서브카테고리 표시) */}
<section> {showBanner ? (
<HeroBanner <section>
subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.id}` }))} <HeroBanner
activeSubId={id} subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.slug}` }))}
/> activeSubId={id}
</section> />
</section>
) : (
<section>
<div className="h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
<div className="flex flex-wrap items-center gap-[8px]">
{siblingBoards.map((b: any) => (
<Link
key={b.id}
href={`/boards/${b.slug}`}
className={
b.id === id
? "px-3 h-[28px] mt-[11px] rounded-full bg-[#F94B37] text-white text-[12px] font-[700] leading-[28px] whitespace-nowrap"
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:text-white border border-white/30 text-[12px] font-[700] leading-[28px] whitespace-nowrap"
}
>
{b.name}
</Link>
))}
</div>
</div>
</section>
)}
{/* 검색/필터 툴바 + 리스트 */} {/* 검색/필터 툴바 + 리스트 */}
<section> <section className="px-[0px] md:px-[30px] ">
<BoardToolbar boardId={id} /> {!isSpecialRanking && !isAttendance && <BoardToolbar boardId={board?.slug} />}
<div className="p-0"> <div className="p-0">
{isSpecialRanking ? ( {isSpecialRanking ? (
<div className="rounded-xl border border-neutral-200 overflow-hidden"> <div className="w-full">
<div className="px-4 py-3 border-b border-neutral-200 flex items-center justify-between bg-[#f6f4f4]"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[32px]">
<h2 className="text-sm text-neutral-700"> ({period})</h2> {rankingItems.map((i, idx) => {
<div className="text-xs text-neutral-500 flex gap-2"> const rank = idx + 1;
<a href={`?period=daily`} className={`px-2 py-0.5 rounded ${period === "daily" ? "bg-neutral-900 text-white" : "bg-white border border-neutral-300"}`}></a> return (
<a href={`?period=weekly`} className={`px-2 py-0.5 rounded ${period === "weekly" ? "bg-neutral-900 text-white" : "bg-white border border-neutral-300"}`}></a> <div key={i.userId} className="border-t border-[#d5d5d5]">
<a href={`?period=monthly`} className={`px-2 py-0.5 rounded ${period === "monthly" ? "bg-neutral-900 text-white" : "bg-white border border-neutral-300"}`}></a> <div className="flex gap-[16px] items-center p-[16px]">
<a href={`?period=all`} className={`px-2 py-0.5 rounded ${period === "all" ? "bg-neutral-900 text-white" : "bg-white border border-neutral-300"}`}></a> <div className="flex items-center gap-[8px] shrink-0">
</div> {(rank === 1 || rank === 2 || rank === 3) && (
</div> <div className="relative w-[20px] h-[20px] shrink-0">
<ol className="divide-y divide-neutral-200"> {rank === 1 && <RankIcon1st />}
{rankingItems.map((i, idx) => ( {rank === 2 && <RankIcon2nd />}
<li key={i.userId} className="px-4 py-3 flex items-center justify-between"> {rank === 3 && <RankIcon3rd />}
<div className="flex items-center gap-3 min-w-0"> </div>
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-900 text-white text-xs">{idx + 1}</span> )}
<span className="truncate text-neutral-900 font-medium">{i.nickname || "회원"}</span> <div className="bg-white border border-[#d5d5d5] px-[16px] py-[5px] rounded-[12px] text-[14px] text-[#5c5c5c] shrink-0">
{rank}
</div>
<div className="flex items-center gap-[10px] shrink-0 pl-0 pr-[15px] py-0">
<UserAvatar src={i.profileImage} alt={i.nickname || "프로필"} width={36} height={36} className="rounded-full" />
<span className="text-[16px] text-[#5c5c5c] leading-[16px] tracking-[-0.28px] whitespace-nowrap">
{i.nickname || "회원"}
</span>
<GradeIcon grade={i.grade} width={20} height={20} />
</div>
</div>
<div className="flex items-center gap-[13px] shrink-0 ml-auto">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0">
<path d="M8.17377 0.0481988C8.1834 0.0326271 8.19596 0.0201252 8.21037 0.0117499C8.22478 0.00337451 8.24062 -0.000628204 8.25656 7.99922e-05C8.2725 0.000788189 8.28806 0.0061865 8.30194 0.0158187C8.31582 0.0254509 8.3276 0.0390338 8.33629 0.0554196L11.3323 5.55973C11.3575 5.60512 11.4038 5.62472 11.4468 5.60718L15.8683 3.80198C15.9441 3.77104 16.0174 3.85666 15.9963 3.95053L14.2584 16H1.74226L0.00344328 3.95156C-0.00125237 3.93019 -0.0011435 3.90765 0.00375838 3.88635C0.00866025 3.86505 0.0181727 3.84576 0.0312889 3.83054C0.0444051 3.81532 0.0606369 3.80472 0.0782661 3.79988C0.0958953 3.79503 0.114266 3.79612 0.131434 3.80302L4.55889 5.61131C4.59846 5.62678 4.64309 5.61131 4.66835 5.57005L8.17461 0.0481988H8.17377Z" fill="#FFB625"/>
</svg>
<span className="text-[16px] font-semibold text-[#5c5c5c] leading-[22px]">{i.points.toLocaleString()}</span>
</div>
</div>
</div> </div>
<div className="shrink-0 text-sm text-neutral-700">{i.points}</div> );
</li> })}
))} </div>
{rankingItems.length === 0 && ( {rankingItems.length === 0 && (
<li className="px-4 py-10 text-center text-neutral-500"> .</li> <div className="px-4 py-10 text-center text-neutral-500"> .</div>
)} )}
</ol> </div>
) : isAttendance ? (
<div className="w-full py-4">
<AttendanceCalendar isLoggedIn={isLoggedIn} />
</div> </div>
) : ( ) : (
<PostList <PostList
boardId={id} boardId={id}
sort={sort} sort={sort}
variant="board" variant="board"
titleHoverOrange
newPostHref={`/posts/new?boardId=${id}${board?.slug ? `&boardSlug=${board.slug}` : ""}`} newPostHref={`/posts/new?boardId=${id}${board?.slug ? `&boardSlug=${board.slug}` : ""}`}
/> />
)} )}

View File

@@ -15,7 +15,7 @@ export default async function BoardsPage() {
<h1></h1> <h1></h1>
<ul style={{ display: "flex", flexDirection: "column", gap: 6 }}> <ul style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{boards?.map((b: any) => ( {boards?.map((b: any) => (
<li key={b.id}><Link href={`/boards/${b.id}`}>{b.name}</Link></li> <li key={b.id}><Link href={`/boards/${b.slug}`}>{b.name}</Link></li>
))} ))}
</ul> </ul>
</div> </div>

View File

@@ -1,13 +1,15 @@
import Link from "next/link";
export function AppFooter() { export function AppFooter() {
return ( return (
<footer className="py-[72px]"> <footer className="py-[72px]">
<div className="text-[#626262] text-[16px] leading-[14px] flex flex-row mb-[30px]"> <div className="text-[#626262] text-[16px] leading-[14px] flex flex-row mb-[30px]">
<div className="flex-1"></div> <div className="flex-1"></div>
<div className="border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></div> <Link href="/privacy" className="border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></Link>
<div className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </div> <Link href="/email-deny" className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </Link>
<div className=" border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </div> <Link href="/legal" className=" border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </Link>
<div className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></div> <Link href="/guide" className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></Link>
<div className="px-[8px] cursor-pointer hover:text-[#2BAF7E]"></div> <Link href="/contact" className="px-[8px] cursor-pointer hover:text-[#2BAF7E]"></Link>
<div className="flex-1"></div> <div className="flex-1"></div>
</div> </div>
<div className="text-[#888] text-center font-[Pretendard] text-[16px] font-normal leading-[100%] mt-[24px]"> <div className="text-[#888] text-center font-[Pretendard] text-[16px] font-normal leading-[100%] mt-[24px]">

Some files were not shown because too many files have changed in this diff Show More