From 13f5942f6a5de1714ceb8816ed52a3a9deaa820c Mon Sep 17 00:00:00 2001 From: iubns Date: Thu, 7 May 2026 01:31:08 +0900 Subject: [PATCH 1/9] fix: jwtPayload type error --- server/src/util/util.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/server/src/util/util.ts b/server/src/util/util.ts index 4193be32..80a85b0e 100644 --- a/server/src/util/util.ts +++ b/server/src/util/util.ts @@ -20,7 +20,11 @@ export async function hasPermission( token: string | undefined, permissionType: PermissionType, ): Promise { - const payload = jwt.verify(token, env.JWT_SECRET) as jwtPayload + if (!token) { + return false + } + + const payload = jwt.verify(token, env.JWT_SECRET) as jwtPayload if (payload.role.Admin) { return true @@ -35,10 +39,6 @@ export async function hasPermission( }, }) - if (!token) { - return false - } - if (!foundUser) { return false } @@ -73,7 +73,7 @@ export async function getUserFromToken(req: express.Request) { return null } try { - const { id } = jwt.verify(token, env.JWT_SECRET) + const { id } = jwt.verify(token, env.JWT_SECRET) as jwtPayload return await userDatabase.findOne({ relations: { community: true, @@ -93,7 +93,7 @@ export async function checkJwt(req: express.Request) { return null } try { - const payload = jwt.verify(token, env.JWT_SECRET) as jwtPayload + const payload = jwt.verify(token, env.JWT_SECRET) as jwtPayload return payload } catch (e) { return null From 88f10bbceadd0bc529929f87c3434e9352cfff3c Mon Sep 17 00:00:00 2001 From: iubns Date: Sun, 10 May 2026 23:02:04 +0900 Subject: [PATCH 2/9] =?UTF-8?q?update:=20=EC=88=9C=EC=9E=A5=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EB=B0=8F=20=EC=A0=84=EC=9A=A9=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/leader/components/Header/index.tsx | 107 ------------------ client/src/app/leader/layout.tsx | 2 +- client/src/app/leader/page.tsx | 15 --- client/src/app/leader/postcard/page.tsx | 2 +- client/src/components/Header/index.tsx | 41 ++++++- 5 files changed, 39 insertions(+), 128 deletions(-) delete mode 100644 client/src/app/leader/components/Header/index.tsx delete mode 100644 client/src/app/leader/page.tsx diff --git a/client/src/app/leader/components/Header/index.tsx b/client/src/app/leader/components/Header/index.tsx deleted file mode 100644 index 9e6817ae..00000000 --- a/client/src/app/leader/components/Header/index.tsx +++ /dev/null @@ -1,107 +0,0 @@ -"use client" - -import { useState } from "react" -import { useRouter } from "next/navigation" -import { Button, Stack } from "@mui/material" -import MenuIcon from "@mui/icons-material/Menu" -import PeopleIcon from "@mui/icons-material/People" -import HeaderDrawer, { DrawerItemsType } from "@/components/Header/Drawer" -import EventNoteIcon from "@mui/icons-material/EventNote" -import HowToRegIcon from "@mui/icons-material/HowToReg" -import useAuth from "@/hooks/useAuth" - -export default function Header() { - const { push } = useRouter() - const { authUserData } = useAuth() - const [isOpen, setOpen] = useState(false) - - function toggleDrawer(value: boolean) { - setOpen(value) - } - - function goToHome() { - push("/leader") - } - - const menu: DrawerItemsType[] = [ - { - title: "순원 관리", - icon: , - path: "/leader/management", - type: "menu", - }, - { - title: "출석 관리", - icon: , - path: "/leader/attendance", - type: "menu", - }, - /*Todo: 다음 수련회때 다시 키기 - { - title: "순원 수련회 접수 조회", - icon: , - path: "/leader/retreat-attendance", - type: "menu", - },*/ - ] - - if (authUserData?.role.VillageLeader || authUserData?.role.Admin) { - menu.push({ - title: "전체 출석 조회", - icon: , - path: "/leader/all-attendance", - type: "menu", - }) - } - - if (authUserData?.role.NewcomerManager || authUserData?.role.Admin) { - menu.push({ - title: "새가족 관리", - icon: , - path: "/leader/newcomer/management", - type: "menu", - }) - } - - return ( - - - - - 순장 - - - - - - ) -} diff --git a/client/src/app/leader/layout.tsx b/client/src/app/leader/layout.tsx index ef28ca83..e4484c47 100644 --- a/client/src/app/leader/layout.tsx +++ b/client/src/app/leader/layout.tsx @@ -1,6 +1,6 @@ "use client" -import Header from "@/app/leader/components/Header" +import Header from "@/components/Header" export default function LeaderLayout({ children, diff --git a/client/src/app/leader/page.tsx b/client/src/app/leader/page.tsx deleted file mode 100644 index f1c3b911..00000000 --- a/client/src/app/leader/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client" - -import useAuth from "@/hooks/useAuth" -import { Stack } from "@mui/material" -import { useEffect } from "react" - -export default function LeaderPage() { - const { isLeaderIfNotExit, authUserData } = useAuth() - - useEffect(() => { - isLeaderIfNotExit("/leader") - }, []) - - return 나중에 일정이나 공지 추가 예정 -} diff --git a/client/src/app/leader/postcard/page.tsx b/client/src/app/leader/postcard/page.tsx index 327522b7..0ac3107b 100644 --- a/client/src/app/leader/postcard/page.tsx +++ b/client/src/app/leader/postcard/page.tsx @@ -5,7 +5,7 @@ import { Community } from "@server/entity/community" import { User } from "@server/entity/user" import { get } from "@/config/api" import { useEffect, useState } from "react" -import Header from "@/app/leader/components/Header" +import Header from "@/app/leader/components/HeaderList" import { useRouter } from "next/navigation" import axios from "@/config/axios" import { useNotification } from "@/hooks/useNotification" diff --git a/client/src/components/Header/index.tsx b/client/src/components/Header/index.tsx index 7c5a954b..19d51816 100644 --- a/client/src/components/Header/index.tsx +++ b/client/src/components/Header/index.tsx @@ -6,7 +6,9 @@ import { useRouter } from "next/navigation" import { Button, Stack } from "@mui/material" import MenuIcon from "@mui/icons-material/Menu" import PeopleIcon from "@mui/icons-material/People" +import EventNoteIcon from "@mui/icons-material/EventNote" import useAuth from "@/hooks/useAuth" +import HowToRegIcon from "@mui/icons-material/HowToReg" export default function Header() { const { push } = useRouter() @@ -42,12 +44,43 @@ export default function Header() { DrawerItems.push({ type: "divider", }) - DrawerItems.push({ - title: "순장 화면", + ;(DrawerItems.push({ + title: "순원 관리", icon: , - path: "/leader", + path: "/leader/management", type: "menu", - }) + }), + DrawerItems.push({ + title: "출석 관리", + icon: , + path: "/leader/attendance", + type: "menu", + })) + /*Todo: 다음 수련회때 다시 키기 + { + title: "순원 수련회 접수 조회", + icon: , + path: "/leader/retreat-attendance", + type: "menu", + },*/ + + if (authUserData?.role.VillageLeader || authUserData?.role.Admin) { + DrawerItems.push({ + title: "전체 출석 조회", + icon: , + path: "/leader/all-attendance", + type: "menu", + }) + } + + if (authUserData?.role.NewcomerManager || authUserData?.role.Admin) { + DrawerItems.push({ + title: "새가족 관리", + icon: , + path: "/leader/newcomer/management", + type: "menu", + }) + } } return ( From 5066502c0170a1a458491df9b4c5392a43448560 Mon Sep 17 00:00:00 2001 From: iubns Date: Sun, 10 May 2026 23:12:35 +0900 Subject: [PATCH 3/9] delete ai service Co-authored-by: Copilot --- server/src/entity/ai/aiChat.ts | 26 --- server/src/entity/ai/aiChatRoom.ts | 29 --- .../migration/1778422197467-DropAiTables.ts | 32 +++ server/src/model/ai.ts | 215 ------------------ server/src/model/dataSource.ts | 5 - server/src/routes/admin/adminRouter.ts | 2 - server/src/routes/admin/ai.ts | 98 -------- 7 files changed, 32 insertions(+), 375 deletions(-) delete mode 100644 server/src/entity/ai/aiChat.ts delete mode 100644 server/src/entity/ai/aiChatRoom.ts create mode 100644 server/src/migration/1778422197467-DropAiTables.ts delete mode 100644 server/src/model/ai.ts delete mode 100644 server/src/routes/admin/ai.ts diff --git a/server/src/entity/ai/aiChat.ts b/server/src/entity/ai/aiChat.ts deleted file mode 100644 index cd2e6e13..00000000 --- a/server/src/entity/ai/aiChat.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm" -import { AIChatRoom } from "./aiChatRoom" - -export enum ChatType { - USER = "user", - AI = "ai", - SYSTEM = "system", -} - -@Entity() -export class AIChat { - @PrimaryGeneratedColumn("uuid") - id: string - - @ManyToOne(() => AIChatRoom, (room) => room.id) - room: AIChatRoom - - @Column({ type: "enum", enum: ChatType }) - type: ChatType - - @Column("text") - message: string - - @Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" }) - createdAt: Date -} diff --git a/server/src/entity/ai/aiChatRoom.ts b/server/src/entity/ai/aiChatRoom.ts deleted file mode 100644 index e1416ffe..00000000 --- a/server/src/entity/ai/aiChatRoom.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - Column, - Entity, - ManyToOne, - OneToMany, - PrimaryGeneratedColumn, -} from "typeorm" -import { AIChat } from "./aiChat" -import { User } from "../user" - -@Entity() -export class AIChatRoom { - @PrimaryGeneratedColumn("uuid") - id: string - - @OneToMany(() => AIChat, (chat) => chat.room, { - cascade: ["insert", "update"], - }) - chats: AIChat[] - - @ManyToOne(() => User, (user) => user.id) - user: User - - @Column() - title: string - - @Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" }) - createdAt: Date -} diff --git a/server/src/migration/1778422197467-DropAiTables.ts b/server/src/migration/1778422197467-DropAiTables.ts new file mode 100644 index 00000000..d55684a0 --- /dev/null +++ b/server/src/migration/1778422197467-DropAiTables.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class DropAiTables1778422197467 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS \`ai_chat\``) + await queryRunner.query(`DROP TABLE IF EXISTS \`ai_chat_room\``) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE \`ai_chat_room\` ( + \`id\` varchar(36) NOT NULL, + \`userId\` varchar(36) COLLATE utf8mb4_general_ci NULL, + \`title\` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + CONSTRAINT \`FK_ai_chat_room_user\` FOREIGN KEY (\`userId\`) REFERENCES \`user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + await queryRunner.query(` + CREATE TABLE \`ai_chat\` ( + \`id\` varchar(36) NOT NULL, + \`roomId\` varchar(36) COLLATE utf8mb4_general_ci NULL, + \`type\` enum ('user', 'ai', 'system') NOT NULL, + \`message\` text COLLATE utf8mb4_general_ci NOT NULL, + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + CONSTRAINT \`FK_ai_chat_roomId\` FOREIGN KEY (\`roomId\`) REFERENCES \`ai_chat_room\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + } +} diff --git a/server/src/model/ai.ts b/server/src/model/ai.ts deleted file mode 100644 index 4163f75b..00000000 --- a/server/src/model/ai.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { AIChat, ChatType } from "../entity/ai/aiChat" -import { AIChatRoom } from "../entity/ai/aiChatRoom" -import { User } from "../entity/user" -import dataSource, { aiChatRoomDatabase } from "./dataSource" - -/* - * AI와 주고받는 메시지 타입 정의 - */ -interface MessageType { - role: "user" | "assistant" | "system" - content: string -} - -interface AiChatResponse { - id: string - content: Array<{ - citations: any - text: string - type: string - }> - model: string - role: string - stop_reason: string | null - stop_sequence: string | null - type: string - usage: any -} - -const AiModel = { - async createNewRoom(user: User, title: string): Promise { - if (title.length > 100) { - title = title.slice(0, 100) - } - const room = await aiChatRoomDatabase.create({ - user: user, - title: title, - }) - await aiChatRoomDatabase.save(room) - return room - }, - - async getUserRooms(user: User) { - const rooms = await aiChatRoomDatabase.find({ - where: { - user: { - id: user.id, - }, - }, - }) - return rooms - }, - - async callSql(query: string) { - const sqlMatch = query.match(/```(?:sql)?\s*([\s\S]*?)\s*```/i) - const sql = sqlMatch ? sqlMatch[1] : query - - const result = await dataSource.query(sql) - return result - }, - - async requestChatAI(messages: AIChat[]) { - // Separate system prompt (assumed to be the first message if it's SYSTEM type) - let systemPrompt = await getSystemPrompt() - const SIZE_LIMIT = 8 - if (messages.length > SIZE_LIMIT) { - messages = messages.slice(messages.length - SIZE_LIMIT, messages.length) - } - let chatMessages = [...messages] - - const body = { - model: "claude-sonnet-4-5-20250929", - max_tokens: 1024, - system: systemPrompt, - messages: chatMessages.map((chat) => { - let role: "user" | "assistant" = "user" - switch (chat.type) { - case ChatType.USER: - role = "user" - break - case ChatType.AI: - role = "assistant" - break - case ChatType.SYSTEM: - // Query Result is treated as User input (context for the AI) - // SQL Query (generated by AI previously) is treated as Assistant output - if (chat.message.startsWith("Query Result:")) { - role = "user" - } else { - role = "assistant" - } - break - } - return { - role: role, - content: chat.message, - } - }), - } - - const response = await fetch( - "https://factchat-cloud.mindlogic.ai/v1/api/anthropic/messages", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${process.env.AI_API_KEY}`, - }, - body: JSON.stringify(body), - }, - ) - const data = (await response.json()) as AiChatResponse - - if (!data.content || data.content.length === 0) { - console.error("AI returned empty content", data) - throw new Error("AI returned empty content") - } - - return { - type: ChatType.AI, - message: data.content[0].text, - } as AIChat - }, - - async getChatRoom(roomId: string, isSystemChatIncluded = false) { - const chatRoom = await aiChatRoomDatabase.findOne({ - select: { - id: true, - title: true, - createdAt: true, - user: { - id: true, - }, - chats: { - id: true, - type: true, - message: true, - createdAt: true, - }, - }, - where: { - id: roomId, - }, - relations: { - chats: true, - user: true, - }, - order: { - chats: { createdAt: "ASC" }, - }, - }) - if (!chatRoom) { - throw new Error("Chat room not found") - } - if (!isSystemChatIncluded) { - chatRoom.chats = chatRoom.chats.filter( - (chat) => chat.type !== ChatType.SYSTEM, - ) - } - return chatRoom - }, -} - -async function getSystemPrompt(): Promise { - const schema = await dataSource.query(` - SELECT - TABLE_NAME, - COLUMN_NAME, - DATA_TYPE, - COLUMN_COMMENT -FROM - INFORMATION_SCHEMA.COLUMNS -WHERE - TABLE_SCHEMA = '${process.env.DB_NAME}' -ORDER BY - TABLE_NAME, - ORDINAL_POSITION; - `) - - return ` - 당신은 수원제일교회 청년부 관리 시스템의 데이터베이스 전문가 AI 비서입니다. - 사용자의 질문을 분석하여 통계를 내거나 정보를 찾기 위해 SQL 쿼리를 작성하고, 이후 제공되는 쿼리 결과를 분석하여 사용자에게 답변을 제공합니다. - - [수원제일교회 청년부 정보] - 수원제일교회 청년부의 이름은 새벽이슬이며, 사역자가 각 마을들을 관리하며 마을들 안에는 다락방이 존재합니다. - 다락방은 하위 조직이 없는 단위이며 다락방의 리더를 순장, 부순장으로 지칭 합니다. - 마을의 리더는 마을장으로 지칭 합니다. - - [데이터베이스 스키마 정보] - ${JSON.stringify(schema, null, 2)} - - [작업 절차] - 1. 사용자의 질문이 데이터 조회가 필요한 경우, 표준 SQL(Mysql 호환) 쿼리를 작성해 주세요. - - 쿼리는 반드시 \`\`\`sql ... \`\`\` 코드 블록 안에 작성해야 합니다. - - 다른 설명 없이 오직 SQL 쿼리만 반환하는 것을 강제합니다. - 2. 만약 입력으로 "Query Result:" 와 함께 데이터가 주어진다면, 그 데이터를 분석하여 사용자의 원래 질문에 대해 친절하게 답변해 주세요. - 3. 조회 이외의 모든 쿼리 예(데이터 삽입, 수정, 삭제 등)은 절대 절대 금지 함으로 작성하지 마세요. - 4. 쿼리 결과가 너무 많이 나오지 않도록 항상 적절한 제한을 걸어 주세요. - 5. 시스템에 악영향을 미칠 수 있거나 부적절한 요청에 대해서는 정중하게 거절하는 답변을 해 주세요. - 6. 오늘 날짜는 ${new Date().getFullYear()}년 ${ - new Date().getMonth() + 1 - }월 ${new Date().getDate()}일 입니다. - - [예시] - User: "저번주 주일예배 출석률 알려줘" - Assistant: - \`\`\`sql - SELECT count(*) as count, isAttend FROM AttendData - LEFT JOIN WorshipSchedule ON AttendData.worshipScheduleId = WorshipSchedule.id - WHERE WorshipSchedule.kind = 1 AND WorshipSchedule.date = '...' - GROUP BY isAttend - \`\`\` - ` -} - -export default AiModel diff --git a/server/src/model/dataSource.ts b/server/src/model/dataSource.ts index b85a5174..b934558e 100644 --- a/server/src/model/dataSource.ts +++ b/server/src/model/dataSource.ts @@ -12,8 +12,6 @@ import { import { WorshipSchedule } from "../entity/worshipSchedule" import { AttendData } from "../entity/attendData" import { WorshipContest } from "../entity/event/worshipContest" -import { AIChat } from "../entity/ai/aiChat" -import { AIChatRoom } from "../entity/ai/aiChatRoom" import { Newcomer } from "../entity/newcomer/newcomer" import { NewcomerEducation } from "../entity/newcomer/newcomerEducation" import { NewcomerManager } from "../entity/newcomer/newcomerManager" @@ -33,9 +31,6 @@ export const sharingTextDatabase = dataSource.getRepository(SharingText) export const sharingImageDatabase = dataSource.getRepository(SharingImage) export const sharingVideoDatabase = dataSource.getRepository(SharingVideo) -export const aiChatDatabase = dataSource.getRepository(AIChat) -export const aiChatRoomDatabase = dataSource.getRepository(AIChatRoom) - export const worshipContestDatabase = dataSource.getRepository(WorshipContest) export const newcomerDatabase = dataSource.getRepository(Newcomer) export const newcomerEducationDatabase = diff --git a/server/src/routes/admin/adminRouter.ts b/server/src/routes/admin/adminRouter.ts index f58e836f..d8a4bee3 100644 --- a/server/src/routes/admin/adminRouter.ts +++ b/server/src/routes/admin/adminRouter.ts @@ -3,7 +3,6 @@ import communityRouter from "./communityRouter" import soonRouter from "./soonRouter" import worshipScheduleRouter from "./worshipSchedule" import dashboard from "./dashboard" -import aiRouter from "./ai" const router = express.Router() @@ -11,6 +10,5 @@ router.use("/community", communityRouter) router.use("/soon", soonRouter) router.use("/worship-schedule", worshipScheduleRouter) router.use("/dashboard", dashboard) -router.use("/ai", aiRouter) export default router diff --git a/server/src/routes/admin/ai.ts b/server/src/routes/admin/ai.ts deleted file mode 100644 index ef81c582..00000000 --- a/server/src/routes/admin/ai.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Router } from "express" -import { getUserFromToken, hasPermissionFromReq } from "../../util/util" -import AiModel from "../../model/ai" -import { AIChat, ChatType } from "../../entity/ai/aiChat" -import { aiChatRoomDatabase } from "../../model/dataSource" -import { PermissionType } from "../../entity/types" - -const router = Router() - -router.post("/ask", async (req, res) => { - const user = await getUserFromToken(req) - if (!user) { - res.status(401).json({ message: "Unauthorized" }) - return - } - - const isAdmin = await hasPermissionFromReq(req, PermissionType.admin) - if (!isAdmin) { - res.status(403).json({ message: "Forbidden" }) - return - } - - const userRequestMessage = req.body.message - let roomId: string = req.body.roomId - if (!roomId) { - const newRoom = await AiModel.createNewRoom(user, userRequestMessage) - roomId = newRoom.id - } - - const chatRoom = await AiModel.getChatRoom(roomId, true) - - const requestChat = new AIChat() - requestChat.room = chatRoom - requestChat.type = ChatType.USER - requestChat.message = userRequestMessage - requestChat.createdAt = new Date() - chatRoom.chats.push(requestChat) - - let responseChat: AIChat - do { - responseChat = await AiModel.requestChatAI(chatRoom.chats) - responseChat.room = chatRoom - responseChat.createdAt = new Date() - if (responseChat.message.includes("```sql")) { - responseChat.type = ChatType.SYSTEM // 쿼리는 시스템으로 저장 - chatRoom.chats.push(responseChat) - const sqlResult = await AiModel.callSql(responseChat.message) - const queryResult = JSON.stringify(sqlResult, null, 2).slice(0, 3000) - const queryChat = new AIChat() - queryChat.room = chatRoom - queryChat.type = ChatType.SYSTEM - queryChat.message = `Query Result:\n${queryResult}` - queryChat.createdAt = new Date() - chatRoom.chats.push(queryChat) - continue - } - responseChat.type = ChatType.AI - chatRoom.chats.push(responseChat) - } while (responseChat.message.includes("```sql")) - - await aiChatRoomDatabase.save(chatRoom) - - const savedChatRoom = await AiModel.getChatRoom(roomId, false) - savedChatRoom.chats.forEach((chat) => { - delete chat.room - }) - res.json(savedChatRoom) -}) - -router.get("/my-rooms", async (req, res) => { - const user = await getUserFromToken(req) - if (!user) { - res.status(401).json({ message: "Unauthorized" }) - return - } - - const rooms = await AiModel.getUserRooms(user) - res.json(rooms) -}) - -router.get("/room/:roomId", async (req, res) => { - const user = await getUserFromToken(req) - if (!user) { - res.status(401).json({ message: "Unauthorized" }) - return - } - - const roomId: string = req.params.roomId as string - const chatRoom = await AiModel.getChatRoom(roomId) - if (chatRoom.user.id !== user.id) { - res.status(403).json({ message: "Forbidden" }) - return - } - - res.json(chatRoom) -}) - -export default router From 292e55f092bd1344fdc3eaabb13b284d048a26a2 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 11 May 2026 01:58:04 +0900 Subject: [PATCH 4/9] =?UTF-8?q?update:=20=EB=A7=81=ED=81=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- client/src/app/admin/ai/LeftList.tsx | 111 ------ client/src/app/admin/ai/chat/Chat.tsx | 138 -------- client/src/app/admin/ai/chat/page.tsx | 105 ------ client/src/app/admin/ai/chat/useAiChat.ts | 96 ------ .../src/app/admin/components/Header/index.tsx | 10 +- client/src/app/admin/link/page.tsx | 319 ++++++++++++++++++ client/src/app/components/LinkCard.tsx | 94 ++++++ client/src/app/components/LinkDetailModal.tsx | 148 ++++++++ client/src/app/link/page.tsx | 109 ++++++ client/src/app/page.tsx | 7 +- .../src/components/Header/UserInformation.tsx | 14 +- client/src/types/link.ts | 13 + server/src/entity/link.ts | 55 +++ server/src/entity/linkClick.ts | 31 ++ server/src/index.ts | 19 +- .../1778422481948-CreateLinkTables.ts | 37 ++ server/src/model/dataSource.ts | 5 + server/src/model/link.ts | 83 +++++ server/src/routes/admin/soonRouter.ts | 23 +- server/src/routes/index.ts | 2 + server/src/routes/link.ts | 196 +++++++++++ 21 files changed, 1138 insertions(+), 477 deletions(-) delete mode 100644 client/src/app/admin/ai/LeftList.tsx delete mode 100644 client/src/app/admin/ai/chat/Chat.tsx delete mode 100644 client/src/app/admin/ai/chat/page.tsx delete mode 100644 client/src/app/admin/ai/chat/useAiChat.ts create mode 100644 client/src/app/admin/link/page.tsx create mode 100644 client/src/app/components/LinkCard.tsx create mode 100644 client/src/app/components/LinkDetailModal.tsx create mode 100644 client/src/app/link/page.tsx create mode 100644 client/src/types/link.ts create mode 100644 server/src/entity/link.ts create mode 100644 server/src/entity/linkClick.ts create mode 100644 server/src/migration/1778422481948-CreateLinkTables.ts create mode 100644 server/src/model/link.ts create mode 100644 server/src/routes/link.ts diff --git a/client/src/app/admin/ai/LeftList.tsx b/client/src/app/admin/ai/LeftList.tsx deleted file mode 100644 index fb5c87e1..00000000 --- a/client/src/app/admin/ai/LeftList.tsx +++ /dev/null @@ -1,111 +0,0 @@ -"use client" - -import useAuth from "@/hooks/useAuth" -import { Stack, Typography, Box, Button } from "@mui/material" -import useAiChat from "./chat/useAiChat" -import { useEffect, useState } from "react" -import { AIChatRoom } from "@server/entity/ai/aiChatRoom" -import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline" -import dayjs from "dayjs" - -export default function AdminAIChatLeftComponent() { - const { authUserData } = useAuth() - const { - getChatRooms, - selectedChatRoomId, - setSelectedChatRoomId, - setSelectedChatRoom, - chatRooms, - } = useAiChat() - - useEffect(() => { - getChatRooms() - }, []) - - return ( - - - - 채팅 목록 - - - {authUserData?.name}님 - - - - - {/* 새 채팅 만들기 버튼 */} - - - {chatRooms.map((room) => ( - setSelectedChatRoomId(room.id)} - sx={{ - p: 2, - cursor: "pointer", - borderRadius: 2, - bgcolor: selectedChatRoomId === room.id ? "white" : "transparent", - boxShadow: - selectedChatRoomId === room.id - ? "0 2px 4px rgba(0,0,0,0.05)" - : "none", - transition: "all 0.2s", - "&:hover": { - bgcolor: - selectedChatRoomId === room.id ? "white" : "rgba(0,0,0,0.04)", - }, - border: - selectedChatRoomId === room.id - ? "1px solid #e0e0e0" - : "1px solid transparent", - }} - > - - {room.title || "새로운 채팅"} - - - {dayjs(room.createdAt).format("YY-MM-DD HH:mm")} - - - ))} - - - ) -} diff --git a/client/src/app/admin/ai/chat/Chat.tsx b/client/src/app/admin/ai/chat/Chat.tsx deleted file mode 100644 index f399c6cc..00000000 --- a/client/src/app/admin/ai/chat/Chat.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client" - -import useAiChat from "./useAiChat" -import { marked } from "marked" -import { AIChat } from "@server/entity/ai/aiChat" -import { Box, Stack, Typography, Avatar, CircularProgress } from "@mui/material" -import SmartToyIcon from "@mui/icons-material/SmartToy" -import PersonIcon from "@mui/icons-material/Person" -import MoreHorizIcon from "@mui/icons-material/MoreHoriz" - -import { useRef, useEffect } from "react" - -export enum ChatType { - USER = "user", - AI = "ai", - SYSTEM = "system", -} - -export default function AdminAIChatComponent() { - const { selectedChatRoom, isAiReplying } = useAiChat() - const scrollRef = useRef(null) - - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollIntoView({ behavior: "smooth" }) - } - }, [selectedChatRoom?.chats, isAiReplying]) - - return ( - - {selectedChatRoom && - selectedChatRoom.chats && - selectedChatRoom.chats.map((chat) => ( - - ))} - {isAiReplying && } -
- - ) -} - -function AiLoadingComponent() { - return ( - - - - - - - - - ) -} - -function ChatComponent({ chat }: { chat: AIChat }) { - const isUser = chat.type === ChatType.USER - - return ( - - - {isUser ? ( - - ) : ( - - )} - - - -
- - - ) -} diff --git a/client/src/app/admin/ai/chat/page.tsx b/client/src/app/admin/ai/chat/page.tsx deleted file mode 100644 index 6f4aa106..00000000 --- a/client/src/app/admin/ai/chat/page.tsx +++ /dev/null @@ -1,105 +0,0 @@ -"use client" - -import { - Button, - Stack, - TextField, - IconButton, - Paper, - Typography, - Alert, -} from "@mui/material" -import AdminAIChatComponent from "./Chat" -import AdminAIChatLeftComponent from "../LeftList" -import useAuth from "@/hooks/useAuth" -import { useState } from "react" -import useAiChat from "./useAiChat" -import SendIcon from "@mui/icons-material/Send" -import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined" - -export default function AdminAIChatPage() { - const [message, setMessage] = useState("") - const { sendMessageToAi, isAiReplying } = useAiChat() - - const handleSendMessage = async () => { - if (!message.trim()) return - const msg = message - setMessage("") // 즉시 초기화 - await sendMessageToAi(msg) - } - - return ( - - - - - - - - - - - • 현재 시범 운영 중인 기능입니다. - - - • 제공되는 토큰이 한정되어 있어 필요한 만큼만 질문해 주세요. - - - • AI의 답변은 참고용으로만 활용 부탁드립니다. - - - - setMessage(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - handleSendMessage() - } - }} - disabled={isAiReplying} - sx={{ - "& .MuiOutlinedInput-root": { - borderRadius: 3, - bgcolor: "#f8f9fa", - }, - }} - /> - - - - - - - ) -} diff --git a/client/src/app/admin/ai/chat/useAiChat.ts b/client/src/app/admin/ai/chat/useAiChat.ts deleted file mode 100644 index bd15f4e3..00000000 --- a/client/src/app/admin/ai/chat/useAiChat.ts +++ /dev/null @@ -1,96 +0,0 @@ -"use client" - -import axios from "@/config/axios" -import { AIChatRoom } from "@server/entity/ai/aiChatRoom" -import { atom, useAtom } from "jotai" -import { useEffect } from "react" - -const SelectedAiCharRoomIdAtom = atom("") -const SelectedAiCharRoomAtom = atom(null) -const AiChatRoomsAtom = atom([]) -const IsAiReplyingAtom = atom(false) - -export default function useAiChat() { - const [selectedChatRoom, setSelectedChatRoom] = useAtom( - SelectedAiCharRoomAtom, - ) - const [selectedChatRoomId, setSelectedChatRoomId] = useAtom( - SelectedAiCharRoomIdAtom, - ) - const [chatRooms, setChatRooms] = useAtom(AiChatRoomsAtom) - const [isAiReplying, setIsAiReplying] = useAtom(IsAiReplyingAtom) - - useEffect(() => { - if (selectedChatRoomId) { - getRoomData(selectedChatRoomId) - } - }, [selectedChatRoomId]) - - async function getChatRooms() { - const response = await axios.get("/admin/ai/my-rooms") - setChatRooms(response.data) - return response - } - - async function sendMessageToAi(message: string) { - // Optimistic Update: 사용자 메시지를 미리 보여줌 - const tempUserChat: any = { - id: `temp-${Date.now()}`, - message, - type: "user", - createdAt: new Date(), - } - - setSelectedChatRoom((prev) => { - if (!prev) { - // 새 채팅방인 경우 임시 방 생성 - return { - id: "temp-room", - title: "New Chat", - chats: [tempUserChat], - createdAt: new Date(), - updatedAt: new Date(), - } as unknown as AIChatRoom - } - return { - ...prev, - chats: [...(prev.chats || []), tempUserChat], - } - }) - - setIsAiReplying(true) - try { - const response = await axios.post("/admin/ai/ask", { - message: message, - roomId: selectedChatRoomId, - }) - setSelectedChatRoom(response.data) - - if (!selectedChatRoomId || selectedChatRoomId !== response.data.id) { - setSelectedChatRoomId(response.data.id) - getChatRooms() - } else { - // 이미 룸이 있어도 채팅 업데이트를 위해 리스트 갱신 (마지막 메세지, 시간 등) - getChatRooms() - } - } finally { - setIsAiReplying(false) - } - } - - async function getRoomData(roomId: string) { - const response = await axios.get(`/admin/ai/room/${roomId}`) - setSelectedChatRoom(response.data) - } - - return { - getChatRooms, - sendMessageToAi, - selectedChatRoom, - setSelectedChatRoom, - selectedChatRoomId, - setSelectedChatRoomId, - chatRooms, - isAiReplying, - } -} diff --git a/client/src/app/admin/components/Header/index.tsx b/client/src/app/admin/components/Header/index.tsx index a7d46f10..9219f9b4 100644 --- a/client/src/app/admin/components/Header/index.tsx +++ b/client/src/app/admin/components/Header/index.tsx @@ -9,7 +9,7 @@ import CommunityIcon from "@mui/icons-material/Groups" import HeaderDrawer, { DrawerItemsType } from "@/components/Header/Drawer" import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline" import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined" -import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome" +import LinkIcon from "@mui/icons-material/Link" export default function AdminHeader() { const { push } = useRouter() @@ -63,13 +63,13 @@ export default function AdminHeader() { path: "/admin/soon/attendance", type: "menu", }, - /* { - title: "AI로 데이터 분석", - icon: , - path: "/admin/ai/chat", + title: "링크 관리", + icon: , + path: "/admin/link", type: "menu", }, + /* { type: "divider", },*/ diff --git a/client/src/app/admin/link/page.tsx b/client/src/app/admin/link/page.tsx new file mode 100644 index 00000000..75c0c048 --- /dev/null +++ b/client/src/app/admin/link/page.tsx @@ -0,0 +1,319 @@ +"use client" + +import { + Box, + Button, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, + Card, + CardContent, + Paper, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + IconButton, + TableContainer, + MenuItem, +} from "@mui/material" +import { useEffect, useState } from "react" +import AddIcon from "@mui/icons-material/Add" +import EditIcon from "@mui/icons-material/Edit" +import DeleteIcon from "@mui/icons-material/Delete" +import { useNotification } from "@/hooks/useNotification" +import axios from "@/config/axios" +import LinkIcon from "@mui/icons-material/Link" + +interface Link { + id?: string + title: string + type: "link" | "text" + url?: string + body?: string + displayOrder: number + isActive: boolean + createdAt?: string + updatedAt?: string +} + +export default function LinkManagePage() { + const { error, success } = useNotification() + const [linkList, setLinkList] = useState([]) + const [openDialog, setOpenDialog] = useState(false) + const [isEditing, setIsEditing] = useState(false) + + const [selectedLink, setSelectedLink] = useState({ + title: "", + type: "link", + url: "", + body: "", + displayOrder: 0, + isActive: true, + }) + + useEffect(() => { + fetchLinks() + }, []) + + async function fetchLinks() { + try { + const { data } = await axios.get("/link") + setLinkList(data) + } catch (err) { + error("링크 목록을 불러올 수 없습니다.") + } + } + + function openAddDialog() { + setSelectedLink({ + title: "", + type: "link", + url: "", + body: "", + displayOrder: 0, + isActive: true, + }) + setIsEditing(false) + setOpenDialog(true) + } + + function openEditDialog(link: Link) { + setSelectedLink({ ...link }) + setIsEditing(true) + setOpenDialog(true) + } + + async function saveLink() { + if (!selectedLink.title) { + error("제목을 입력해주세요.") + return + } + if (selectedLink.type === "link" && !selectedLink.url) { + error("링크 타입일 때 URL을 입력해주세요.") + return + } + if (selectedLink.type === "text" && !selectedLink.body) { + error("텍스트 타입일 때 내용을 입력해주세요.") + return + } + + try { + if (isEditing && selectedLink.id) { + await axios.put(`/link/${selectedLink.id}`, selectedLink) + success("링크가 수정되었습니다.") + } else { + await axios.post("/link", selectedLink) + success("링크가 추가되었습니다.") + } + await fetchLinks() + setOpenDialog(false) + } catch (err) { + error("링크 저장에 실패했습니다.") + } + } + + async function deleteLink(id: string) { + if (!confirm("정말 삭제하시겠습니까?")) { + return + } + + try { + await axios.delete(`/link/${id}`) + success("링크가 삭제되었습니다.") + await fetchLinks() + } catch (err) { + error("링크 삭제에 실패했습니다.") + } + } + + return ( + + + + + + + + + 링크 관리 + + + + + + {linkList.length === 0 ? ( + + 추가된 링크가 없습니다. + + ) : ( + + + + + 순서 + 제목 + 타입 + URL + 활성 + 작업 + + + + {linkList.map((link) => ( + + {link.displayOrder} + {link.title} + + {link.type === "link" ? "🔗 링크" : "📝 텍스트"} + + + {link.url || "—"} + + + {link.isActive ? "✓" : "✗"} + + + openEditDialog(link)} + color="primary" + > + + + link.id && deleteLink(link.id)} + color="error" + > + + + + + ))} + +
+
+ )} +
+
+
+ + setOpenDialog(false)} fullWidth> + {isEditing ? "링크 수정" : "링크 추가"} + + + + setSelectedLink({ ...selectedLink, title: e.target.value }) + } + placeholder="링크 제목을 입력하세요" + /> + + setSelectedLink({ + ...selectedLink, + type: e.target.value as "link" | "text", + url: e.target.value === "text" ? "" : selectedLink.url, + }) + } + > + 🔗 링크 + 📝 텍스트 + + {selectedLink.type === "link" && ( + + setSelectedLink({ ...selectedLink, url: e.target.value }) + } + placeholder="https://example.com" + /> + )} + {selectedLink.type === "text" && ( + + setSelectedLink({ ...selectedLink, body: e.target.value }) + } + placeholder="텍스트 내용을 입력하세요" + /> + )} + + setSelectedLink({ + ...selectedLink, + displayOrder: parseInt(e.target.value), + }) + } + /> + + + setSelectedLink({ + ...selectedLink, + isActive: e.target.checked, + }) + } + /> + 활성화 + + + + + + + + +
+ ) +} diff --git a/client/src/app/components/LinkCard.tsx b/client/src/app/components/LinkCard.tsx new file mode 100644 index 00000000..2b0a32e0 --- /dev/null +++ b/client/src/app/components/LinkCard.tsx @@ -0,0 +1,94 @@ +import { Box, Card, Typography } from "@mui/material" +import { OpenInNew as OpenInNewIcon } from "@mui/icons-material" +import { Link } from "@server/entity/link" + +interface LinkCardProps { + link: Link + onClick: (link: Link) => void +} + +export default function LinkCard({ link, onClick }: LinkCardProps) { + return ( + onClick(link)} + sx={{ + cursor: "pointer", + borderRadius: "30px", // 링크트리 스타일 둥근 버튼 모양 + boxShadow: "none", + border: "1px solid transparent", + bgcolor: "white", + transition: "all 0.2s ease-in-out", + "&:hover": { + transform: "scale(1.02)", + boxShadow: "0px 4px 15px rgba(0,0,0,0.06)", + border: "1px solid #e0e0e0", + }, + }} + > + + {/* 좌측 여백/아이콘 - 균형 맞추기용 */} + + {link.type === "link" ? "🔗" : "📝"} + + + {/* 중앙 텍스트 */} + + + {link.title} + + + + {/* 우측 아이콘 */} + + {link.type === "link" ? ( + + ) : ( + + ⋯ + + )} + + + + ) +} diff --git a/client/src/app/components/LinkDetailModal.tsx b/client/src/app/components/LinkDetailModal.tsx new file mode 100644 index 00000000..ec54d7e7 --- /dev/null +++ b/client/src/app/components/LinkDetailModal.tsx @@ -0,0 +1,148 @@ +import { + Box, + Button, + Dialog, + DialogContent, + DialogTitle, + IconButton, + Typography, +} from "@mui/material" +import { + Close as CloseIcon, + OpenInNew as OpenInNewIcon, +} from "@mui/icons-material" +import { Link } from "@server/entity/link" + +interface LinkDetailModalProps { + open: boolean + link: Link | null + onClose: () => void + onOpenLink: (link: Link) => void +} + +export default function LinkDetailModal({ + open, + link, + onClose, + onOpenLink, +}: LinkDetailModalProps) { + if (!link) return null + + return ( + + + + {link.type === "link" ? "🔗" : "📝"} + {link.title} + + + + + + + {link.type === "link" && link.url && ( + + + {link.url} + + + + )} + {link.type === "text" && link.body && ( + + + {link.body} + + + )} + + + ) +} diff --git a/client/src/app/link/page.tsx b/client/src/app/link/page.tsx new file mode 100644 index 00000000..14077a93 --- /dev/null +++ b/client/src/app/link/page.tsx @@ -0,0 +1,109 @@ +"use client" + +import { Stack, Box, CircularProgress, Typography } from "@mui/material" +import { useEffect, useState } from "react" +import axios from "axios" +import { Link } from "@server/entity/link" +import LinkCard from "@/app/components/LinkCard" +import LinkDetailModal from "@/app/components/LinkDetailModal" + +export default function Index() { + const [links, setLinks] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedLink, setSelectedLink] = useState(null) + const [openModal, setOpenModal] = useState(false) + + useEffect(() => { + fetchLinks() + }, []) + + async function fetchLinks() { + try { + setLoading(true) + const response = await axios.get("/link") + const sortedLinks = response.data.sort( + (a: Link, b: Link) => a.displayOrder - b.displayOrder, + ) + setLinks(sortedLinks) + } catch (error) { + console.error("Error fetching links:", error) + } finally { + setLoading(false) + } + } + + async function handleCardClick(link: Link) { + if (link.type === "link" && link.url) { + window.open(link.url, "_blank") + } else { + setSelectedLink(link) + setOpenModal(true) + } + + // 링크 클릭 기록 + try { + await axios.post(`/link/${link.id}/click`, { + userAgent: navigator.userAgent, + }) + } catch (error) { + console.error("Error recording click:", error) + } + } + + async function handleOpenLink(link: Link) { + if (link.type === "link" && link.url) { + window.open(link.url, "_blank") + } + } + + function handleCloseModal() { + setOpenModal(false) + setTimeout(() => setSelectedLink(null), 300) + } + + return ( + + + {loading ? ( + + + + ) : links.length === 0 ? ( + + 등록된 링크가 없습니다. + + ) : ( + + {links.map((link) => ( + + ))} + + )} + + + + + ) +} diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx index e86a4248..7eba8f3e 100644 --- a/client/src/app/page.tsx +++ b/client/src/app/page.tsx @@ -2,9 +2,10 @@ import { Stack } from "@mui/material" import Header from "@/components/Header/index" -import { useEffect } from "react" +import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import useAuth from "@/hooks/useAuth" +import Link from "@/app/link/page" export default function Index() { const { isLogin } = useAuth() @@ -19,9 +20,11 @@ export default function Index() { push("/common/login") } } + return ( - +
+ ) } diff --git a/client/src/components/Header/UserInformation.tsx b/client/src/components/Header/UserInformation.tsx index 98d67b99..103da7b0 100644 --- a/client/src/components/Header/UserInformation.tsx +++ b/client/src/components/Header/UserInformation.tsx @@ -16,32 +16,32 @@ export default function UserInformation() { sx={{ background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", color: "white", - p: 3, + p: 2.5, textAlign: "center", }} > {authUserData.name.charAt(0)} - + {authUserData.name} - + {authUserData.yearOfBirth}년생 diff --git a/client/src/types/link.ts b/client/src/types/link.ts new file mode 100644 index 00000000..19f24688 --- /dev/null +++ b/client/src/types/link.ts @@ -0,0 +1,13 @@ +//Todo: enum 문제 때문에 존재함, 해결한 뒤 서버 types로 대체 필요 + +export interface Link { + id: string + title: string + type: "link" | "text" + url?: string + body?: string + displayOrder: number + isActive: boolean + createdAt?: string + updatedAt?: string +} diff --git a/server/src/entity/link.ts b/server/src/entity/link.ts new file mode 100644 index 00000000..eba9ecba --- /dev/null +++ b/server/src/entity/link.ts @@ -0,0 +1,55 @@ +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm" +import { LinkClick } from "./linkClick" + +export enum LinkType { + LINK = "link", + TEXT = "text", +} + +@Entity() +export class Link { + @PrimaryGeneratedColumn("uuid") + id!: string + + @Column() + title!: string + + @Column({ type: "enum", enum: LinkType, default: LinkType.LINK }) + type!: LinkType + + @Column({ nullable: true }) + url?: string + + @Column({ type: "text", nullable: true }) + body?: string + + @Column({ type: "int", default: 0 }) + displayOrder!: number + + @Column({ type: "boolean", default: true }) + isActive!: boolean + + @OneToMany(() => LinkClick, (click) => click.link, { + cascade: ["insert", "update"], + }) + clicks!: LinkClick[] + + @CreateDateColumn({ + type: "timestamp", + default: () => "CURRENT_TIMESTAMP(6)", + }) + createdAt!: Date + + @UpdateDateColumn({ + type: "timestamp", + default: () => "CURRENT_TIMESTAMP(6)", + }) + updatedAt!: Date +} diff --git a/server/src/entity/linkClick.ts b/server/src/entity/linkClick.ts new file mode 100644 index 00000000..f2cc9229 --- /dev/null +++ b/server/src/entity/linkClick.ts @@ -0,0 +1,31 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm" +import { Link } from "./link" + +@Entity() +export class LinkClick { + @PrimaryGeneratedColumn("uuid") + id!: string + + @ManyToOne(() => Link, (link) => link.clicks, { + onDelete: "CASCADE", + }) + link!: Link + + @Column({ type: "varchar", length: 500, nullable: true }) + userAgent!: string + + @Column({ type: "varchar", length: 45, nullable: true }) + ipAddress!: string + + @CreateDateColumn({ + type: "timestamp", + default: () => "CURRENT_TIMESTAMP(6)", + }) + clickedAt!: Date +} diff --git a/server/src/index.ts b/server/src/index.ts index 20f37a68..3e0fe556 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -20,8 +20,13 @@ app.use( "https://nuon-dev.iubns.net", ], credentials: true, - }) + }), ) + +app.get("/", async (req, res) => { + res.send("running server") +}) + app.use("/", apiRouter) const target = process.env.NEXT_PUBLIC_API_TARGET @@ -33,10 +38,10 @@ if (target === "local") { } else if (target === "dev") { port = 8001 var privateKey = fs.readFileSync( - "/etc/letsencrypt/live/nuon-dev.iubns.net/privkey.pem" + "/etc/letsencrypt/live/nuon-dev.iubns.net/privkey.pem", ) var certificate = fs.readFileSync( - "/etc/letsencrypt/live/nuon-dev.iubns.net/cert.pem" + "/etc/letsencrypt/live/nuon-dev.iubns.net/cert.pem", ) var ca = fs.readFileSync("/etc/letsencrypt/live/nuon-dev.iubns.net/chain.pem") const credentials = { key: privateKey, cert: certificate, ca: ca } @@ -44,10 +49,10 @@ if (target === "local") { server = https.createServer(credentials, app) } else if (target === "prod") { var privateKey = fs.readFileSync( - "/etc/letsencrypt/live/nuon.iubns.net/privkey.pem" + "/etc/letsencrypt/live/nuon.iubns.net/privkey.pem", ) var certificate = fs.readFileSync( - "/etc/letsencrypt/live/nuon.iubns.net/cert.pem" + "/etc/letsencrypt/live/nuon.iubns.net/cert.pem", ) var ca = fs.readFileSync("/etc/letsencrypt/live/nuon.iubns.net/chain.pem") const credentials = { key: privateKey, cert: certificate, ca: ca } @@ -59,7 +64,3 @@ server.listen(port, async () => { await Promise.all([dataSource.initialize()]) console.log("start server") }) - -app.get("/", async (req, res) => { - res.send("running server") -}) diff --git a/server/src/migration/1778422481948-CreateLinkTables.ts b/server/src/migration/1778422481948-CreateLinkTables.ts new file mode 100644 index 00000000..5f910bb0 --- /dev/null +++ b/server/src/migration/1778422481948-CreateLinkTables.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class CreateLinkTables1778422481948 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE \`link\` ( + \`id\` varchar(36) NOT NULL, + \`title\` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, + \`type\` enum ('link', 'text') COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'link', + \`url\` varchar(2048) COLLATE utf8mb4_general_ci NULL, + \`body\` text COLLATE utf8mb4_general_ci NULL, + \`displayOrder\` int NOT NULL DEFAULT 0, + \`isActive\` boolean NOT NULL DEFAULT true, + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`updatedAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + await queryRunner.query(` + CREATE TABLE \`link_click\` ( + \`id\` varchar(36) NOT NULL, + \`linkId\` varchar(36) COLLATE utf8mb4_general_ci NOT NULL, + \`userAgent\` varchar(500) COLLATE utf8mb4_general_ci NULL, + \`ipAddress\` varchar(45) COLLATE utf8mb4_general_ci NULL, + \`clickedAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (\`id\`), + INDEX \`IDX_link_click_linkId\` (\`linkId\`), + CONSTRAINT \`FK_link_click_link\` FOREIGN KEY (\`linkId\`) REFERENCES \`link\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS \`link_click\``) + await queryRunner.query(`DROP TABLE IF EXISTS \`link\``) + } +} diff --git a/server/src/model/dataSource.ts b/server/src/model/dataSource.ts index b934558e..23993263 100644 --- a/server/src/model/dataSource.ts +++ b/server/src/model/dataSource.ts @@ -15,6 +15,8 @@ import { WorshipContest } from "../entity/event/worshipContest" import { Newcomer } from "../entity/newcomer/newcomer" import { NewcomerEducation } from "../entity/newcomer/newcomerEducation" import { NewcomerManager } from "../entity/newcomer/newcomerManager" +import { Link } from "../entity/link" +import { LinkClick } from "../entity/linkClick" const dataSource = new DataSource(require("../../ormconfig.js")) @@ -37,4 +39,7 @@ export const newcomerEducationDatabase = dataSource.getRepository(NewcomerEducation) export const newcomerManagerDatabase = dataSource.getRepository(NewcomerManager) +export const linkDatabase = dataSource.getRepository(Link) +export const linkClickDatabase = dataSource.getRepository(LinkClick) + export default dataSource diff --git a/server/src/model/link.ts b/server/src/model/link.ts new file mode 100644 index 00000000..3ef4780c --- /dev/null +++ b/server/src/model/link.ts @@ -0,0 +1,83 @@ +import { linkDatabase, linkClickDatabase } from "../model/dataSource" +import { Link, LinkType } from "../entity/link" +import { LinkClick } from "../entity/linkClick" + +const LinkModel = { + async getAllLinks(): Promise { + return linkDatabase.find({ + where: { isActive: true }, + order: { + displayOrder: "ASC", + }, + }) + }, + + async getLinkById(id: string): Promise { + return linkDatabase.findOne({ + where: { id }, + }) + }, + + async createLink( + title: string, + type: LinkType = LinkType.LINK, + url?: string, + body?: string, + displayOrder: number = 0, + ): Promise { + const link = linkDatabase.create({ + title, + type, + url, + body, + displayOrder, + isActive: true, + }) + return linkDatabase.save(link) + }, + + async updateLink(id: string, data: Partial): Promise { + await linkDatabase.update(id, data) + return this.getLinkById(id) + }, + + async deactivateLink(id: string): Promise { + await linkDatabase.update(id, { isActive: false }) + }, + + async recordClick( + linkId: string, + userAgent?: string, + ipAddress?: string, + ): Promise { + const click = linkClickDatabase.create({ + link: { id: linkId }, + userAgent, + ipAddress, + }) + return linkClickDatabase.save(click) + }, + + async getClickStats( + linkId: string, + ): Promise<{ totalClicks: number; clicks: LinkClick[] }> { + const clicks = await linkClickDatabase.find({ + where: { link: { id: linkId } }, + order: { clickedAt: "DESC" }, + }) + return { + totalClicks: clicks.length, + clicks, + } + }, + + async updateDisplayOrder( + orders: Array<{ id: string; displayOrder: number }>, + ): Promise { + for (const order of orders) { + await linkDatabase.update(order.id, { displayOrder: order.displayOrder }) + } + }, +} + +export default LinkModel diff --git a/server/src/routes/admin/soonRouter.ts b/server/src/routes/admin/soonRouter.ts index 03177e38..7a94b9b9 100644 --- a/server/src/routes/admin/soonRouter.ts +++ b/server/src/routes/admin/soonRouter.ts @@ -88,7 +88,7 @@ router.delete("/delete-user/:id", async (req, res) => { }) router.post("/get-soon-list", async (req, res) => { - const communityIds = req.body.ids + const communityIds = req.body.ids as string | undefined if (!communityIds) { res.status(400).send({ message: "No community IDs provided" }) @@ -285,14 +285,29 @@ router.post("/update-attendance-bulk", async (req, res) => { const { userId, isAttend, memo } = item ?? {} if (!userId || !isAttend) { - return { index, userId, status: "invalid", error: "Missing required fields" } + return { + index, + userId, + status: "invalid", + error: "Missing required fields", + } } if (!(Object.values(AttendStatus) as string[]).includes(isAttend)) { - return { index, userId, status: "invalid", error: "Invalid isAttend value" } + return { + index, + userId, + status: "invalid", + error: "Invalid isAttend value", + } } if (memo !== undefined && memo !== null) { if (typeof memo !== "string") { - return { index, userId, status: "invalid", error: "Invalid memo type" } + return { + index, + userId, + status: "invalid", + error: "Invalid memo type", + } } if (memo.length > 500) { return { index, userId, status: "invalid", error: "Memo too long" } diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 521a28aa..741d88a8 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -9,6 +9,7 @@ import inOutInfoRouter from "./retreat/inOutInfoRouter" import soonRouter from "./soon/soonRouter" import newcomerRouter from "./newcomer/newcomerRouter" import eventRouter from "./event" +import linkRouter from "./link" const router: Router = express.Router() @@ -23,5 +24,6 @@ router.use("/soon", soonRouter) router.use("/newcomer", newcomerRouter) router.use("/event", eventRouter) +router.use("/link", linkRouter) export default router diff --git a/server/src/routes/link.ts b/server/src/routes/link.ts new file mode 100644 index 00000000..9bdfd7f6 --- /dev/null +++ b/server/src/routes/link.ts @@ -0,0 +1,196 @@ +import express, { Router } from "express" +import LinkModel from "../model/link" +import { getUserFromToken, hasPermissionFromReq } from "../util/util" +import { PermissionType } from "../entity/types" + +const router: Router = express.Router() + +router.get("/", async (req, res) => { + try { + const links = await LinkModel.getAllLinks() + res.status(200).json(links) + } catch (error) { + console.error("Error fetching links:", error) + res.status(500).json({ error: "Failed to fetch links" }) + } +}) + +router.get("/:id/stats", async (req, res) => { + try { + const user = await getUserFromToken(req) + if (!user) { + res.status(401).json({ error: "Unauthorized" }) + return + } + + const isAdmin = await hasPermissionFromReq(req, PermissionType.admin) + if (!isAdmin) { + res.status(403).json({ error: "Forbidden" }) + return + } + + const { id } = req.params + const stats = await LinkModel.getClickStats(id) + res.status(200).json(stats) + } catch (error) { + console.error("Error fetching link stats:", error) + res.status(500).json({ error: "Failed to fetch link stats" }) + } +}) + +router.get("/:id", async (req, res) => { + try { + const { id } = req.params + const link = await LinkModel.getLinkById(id) + if (!link) { + res.status(404).json({ error: "Link not found" }) + return + } + res.status(200).json(link) + } catch (error) { + console.error("Error fetching link:", error) + res.status(500).json({ error: "Failed to fetch link" }) + } +}) + +router.post("/", async (req, res) => { + try { + const user = await getUserFromToken(req) + if (!user) { + res.status(401).json({ error: "Unauthorized" }) + return + } + + const isAdmin = await hasPermissionFromReq(req, PermissionType.admin) + if (!isAdmin) { + res.status(403).json({ error: "Forbidden" }) + return + } + + const { title, type = "link", url, body, displayOrder } = req.body + if (!title) { + res.status(400).json({ error: "Missing required field: title" }) + return + } + if (type === "link" && !url) { + res.status(400).json({ error: "URL is required for link type" }) + return + } + if (type === "text" && !body) { + res.status(400).json({ error: "Body is required for text type" }) + return + } + + const link = await LinkModel.createLink( + title, + type, + url, + body, + displayOrder || 0, + ) + res.status(201).json(link) + } catch (error) { + console.error("Error creating link:", error) + res.status(500).json({ error: "Failed to create link" }) + } +}) + +router.put("/:id", async (req, res) => { + try { + const user = await getUserFromToken(req) + if (!user) { + res.status(401).json({ error: "Unauthorized" }) + return + } + + const isAdmin = await hasPermissionFromReq(req, PermissionType.admin) + if (!isAdmin) { + res.status(403).json({ error: "Forbidden" }) + return + } + + const { id } = req.params + const link = await LinkModel.updateLink(id, req.body) + if (!link) { + res.status(404).json({ error: "Link not found" }) + return + } + res.status(200).json(link) + } catch (error) { + console.error("Error updating link:", error) + res.status(500).json({ error: "Failed to update link" }) + } +}) + +router.delete("/:id", async (req, res) => { + try { + const user = await getUserFromToken(req) + if (!user) { + res.status(401).json({ error: "Unauthorized" }) + return + } + + const isAdmin = await hasPermissionFromReq(req, PermissionType.admin) + if (!isAdmin) { + res.status(403).json({ error: "Forbidden" }) + return + } + + const { id } = req.params + await LinkModel.deactivateLink(id) + res.status(200).json({ message: "Link deleted successfully" }) + } catch (error) { + console.error("Error deleting link:", error) + res.status(500).json({ error: "Failed to delete link" }) + } +}) + +router.post("/:id/click", async (req, res) => { + try { + const { id } = req.params + const userAgent = req.headers["user-agent"] + const ipAddress = req.ip || req.socket.remoteAddress + + const link = await LinkModel.getLinkById(id) + if (!link) { + res.status(404).json({ error: "Link not found" }) + return + } + + await LinkModel.recordClick(id, userAgent, ipAddress) + res.status(200).json({ message: "Click recorded" }) + } catch (error) { + console.error("Error recording click:", error) + res.status(500).json({ error: "Failed to record click" }) + } +}) + +router.put("/order/update", async (req, res) => { + try { + const user = await getUserFromToken(req) + if (!user) { + res.status(401).json({ error: "Unauthorized" }) + return + } + + const isAdmin = await hasPermissionFromReq(req, PermissionType.admin) + if (!isAdmin) { + res.status(403).json({ error: "Forbidden" }) + return + } + + const { orders } = req.body + if (!Array.isArray(orders)) { + res.status(400).json({ error: "Invalid orders format" }) + return + } + + await LinkModel.updateDisplayOrder(orders) + res.status(200).json({ message: "Display order updated" }) + } catch (error) { + console.error("Error updating order:", error) + res.status(500).json({ error: "Failed to update order" }) + } +}) + +export default router From f26894ad2f1e3955ac04b009bdb78a455ef5b516 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 11 May 2026 02:02:40 +0900 Subject: [PATCH 5/9] =?UTF-8?q?update:=20=EB=A7=81=ED=81=AC=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- .../app/admin/link/components/LinkDialog.tsx | 121 +++++++++++ .../app/admin/link/components/LinkTable.tsx | 105 ++++++++++ client/src/app/admin/link/page.tsx | 195 ++---------------- 3 files changed, 241 insertions(+), 180 deletions(-) create mode 100644 client/src/app/admin/link/components/LinkDialog.tsx create mode 100644 client/src/app/admin/link/components/LinkTable.tsx diff --git a/client/src/app/admin/link/components/LinkDialog.tsx b/client/src/app/admin/link/components/LinkDialog.tsx new file mode 100644 index 00000000..aa29b5e7 --- /dev/null +++ b/client/src/app/admin/link/components/LinkDialog.tsx @@ -0,0 +1,121 @@ +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + MenuItem, + Stack, + TextField, + Typography, +} from "@mui/material" +import { Link } from "./LinkTable" + +interface LinkDialogProps { + open: boolean + isEditing: boolean + selectedLink: Link + onClose: () => void + onSave: () => void + onChange: (link: Link) => void +} + +export default function LinkDialog({ + open, + isEditing, + selectedLink, + onClose, + onSave, + onChange, +}: LinkDialogProps) { + return ( + + {isEditing ? "링크 수정" : "링크 추가"} + + + + onChange({ ...selectedLink, title: e.target.value }) + } + placeholder="링크 제목을 입력하세요" + /> + + onChange({ + ...selectedLink, + type: e.target.value as "link" | "text", + url: e.target.value === "text" ? "" : selectedLink.url, + }) + } + > + 🔗 링크 + 📝 텍스트 + + {selectedLink.type === "link" && ( + + onChange({ ...selectedLink, url: e.target.value }) + } + placeholder="https://example.com" + /> + )} + {selectedLink.type === "text" && ( + + onChange({ ...selectedLink, body: e.target.value }) + } + placeholder="텍스트 내용을 입력하세요" + /> + )} + + onChange({ + ...selectedLink, + displayOrder: parseInt(e.target.value), + }) + } + /> + + + onChange({ + ...selectedLink, + isActive: e.target.checked, + }) + } + /> + 활성화 + + + + + + + + + ) +} diff --git a/client/src/app/admin/link/components/LinkTable.tsx b/client/src/app/admin/link/components/LinkTable.tsx new file mode 100644 index 00000000..06d77c24 --- /dev/null +++ b/client/src/app/admin/link/components/LinkTable.tsx @@ -0,0 +1,105 @@ +import { + IconButton, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material" +import EditIcon from "@mui/icons-material/Edit" +import DeleteIcon from "@mui/icons-material/Delete" + +export interface Link { + id?: string + title: string + type: "link" | "text" + url?: string + body?: string + displayOrder: number + isActive: boolean + createdAt?: string + updatedAt?: string +} + +interface LinkTableProps { + linkList: Link[] + onEdit: (link: Link) => void + onDelete: (id: string) => void +} + +export default function LinkTable({ + linkList, + onEdit, + onDelete, +}: LinkTableProps) { + if (linkList.length === 0) { + return ( + + 추가된 링크가 없습니다. + + ) + } + + return ( + + + + + 순서 + 제목 + 타입 + URL + 활성 + 작업 + + + + {linkList.map((link) => ( + + {link.displayOrder} + {link.title} + + {link.type === "link" ? "🔗 링크" : "📝 텍스트"} + + + {link.url || "—"} + + {link.isActive ? "✓" : "✗"} + + onEdit(link)} + color="primary" + > + + + link.id && onDelete(link.id)} + color="error" + > + + + + + ))} + +
+
+ ) +} diff --git a/client/src/app/admin/link/page.tsx b/client/src/app/admin/link/page.tsx index 75c0c048..dd74d8bb 100644 --- a/client/src/app/admin/link/page.tsx +++ b/client/src/app/admin/link/page.tsx @@ -4,43 +4,17 @@ import { Box, Button, Stack, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TextField, Typography, Card, CardContent, - Paper, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - IconButton, - TableContainer, - MenuItem, } from "@mui/material" import { useEffect, useState } from "react" import AddIcon from "@mui/icons-material/Add" -import EditIcon from "@mui/icons-material/Edit" -import DeleteIcon from "@mui/icons-material/Delete" import { useNotification } from "@/hooks/useNotification" import axios from "@/config/axios" import LinkIcon from "@mui/icons-material/Link" - -interface Link { - id?: string - title: string - type: "link" | "text" - url?: string - body?: string - displayOrder: number - isActive: boolean - createdAt?: string - updatedAt?: string -} +import LinkTable, { Link } from "./components/LinkTable" +import LinkDialog from "./components/LinkDialog" export default function LinkManagePage() { const { error, success } = useNotification() @@ -158,162 +132,23 @@ export default function LinkManagePage() {
- {linkList.length === 0 ? ( - - 추가된 링크가 없습니다. - - ) : ( - - - - - 순서 - 제목 - 타입 - URL - 활성 - 작업 - - - - {linkList.map((link) => ( - - {link.displayOrder} - {link.title} - - {link.type === "link" ? "🔗 링크" : "📝 텍스트"} - - - {link.url || "—"} - - - {link.isActive ? "✓" : "✗"} - - - openEditDialog(link)} - color="primary" - > - - - link.id && deleteLink(link.id)} - color="error" - > - - - - - ))} - -
-
- )} + - setOpenDialog(false)} fullWidth> - {isEditing ? "링크 수정" : "링크 추가"} - - - - setSelectedLink({ ...selectedLink, title: e.target.value }) - } - placeholder="링크 제목을 입력하세요" - /> - - setSelectedLink({ - ...selectedLink, - type: e.target.value as "link" | "text", - url: e.target.value === "text" ? "" : selectedLink.url, - }) - } - > - 🔗 링크 - 📝 텍스트 - - {selectedLink.type === "link" && ( - - setSelectedLink({ ...selectedLink, url: e.target.value }) - } - placeholder="https://example.com" - /> - )} - {selectedLink.type === "text" && ( - - setSelectedLink({ ...selectedLink, body: e.target.value }) - } - placeholder="텍스트 내용을 입력하세요" - /> - )} - - setSelectedLink({ - ...selectedLink, - displayOrder: parseInt(e.target.value), - }) - } - /> - - - setSelectedLink({ - ...selectedLink, - isActive: e.target.checked, - }) - } - /> - 활성화 - - - - - - - - + setOpenDialog(false)} + onSave={saveLink} + onChange={setSelectedLink} + /> ) } From 82d6d77b8c59ab763590d9124573be9384c7e882 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 11 May 2026 02:14:36 +0900 Subject: [PATCH 6/9] =?UTF-8?q?update:=20=EC=BB=A4=EB=B0=8B=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=B9=8C=EB=93=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .husky/pre-commit | 2 ++ client/package.json | 4 ++- client/pnpm-lock.yaml | 35 +++++++++++++++++++++++++ client/src/app/leader/postcard/page.tsx | 2 +- 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100755 .husky/pre-commit diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..06ee6a04 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +#!/bin/sh +cd client && pnpm run build diff --git a/client/package.json b/client/package.json index e007a2bf..77273f29 100644 --- a/client/package.json +++ b/client/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start -p 8080", "lint": "next lint", - "deploy": "bash ./_scripts/deploy.sh" + "deploy": "bash ./_scripts/deploy.sh", + "prepare": "husky" }, "dependencies": { "@emotion/css": "11.13.5", @@ -39,6 +40,7 @@ "@types/react-dom": "19.1.6", "eslint": "9.28.0", "eslint-config-next": "15.3.3", + "husky": "^9.1.7", "ts-loader": "^9.5.1", "typescript": "5.8.3" } diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index daa15106..62a85c79 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: eslint-config-next: specifier: 15.3.3 version: 15.3.3(eslint@9.28.0)(typescript@5.8.3) + husky: + specifier: ^9.1.7 + version: 9.1.7 ts-loader: specifier: ^9.5.1 version: 9.5.1(typescript@5.8.3)(webpack@5.97.1) @@ -895,72 +898,85 @@ packages: resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.1.0': resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.1.0': resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.1.0': resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.1.0': resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.1.0': resolution: {integrity: sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.1.0': resolution: {integrity: sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.2': resolution: {integrity: sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.2': resolution: {integrity: sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.2': resolution: {integrity: sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.2': resolution: {integrity: sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.2': resolution: {integrity: sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.2': resolution: {integrity: sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.2': resolution: {integrity: sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==} @@ -1123,24 +1139,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.3.3': resolution: {integrity: sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.3.3': resolution: {integrity: sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.3.3': resolution: {integrity: sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.3.3': resolution: {integrity: sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==} @@ -1428,41 +1448,49 @@ packages: resolution: {integrity: sha512-7vFWHLCCNFLEQlmwKQfVy066ohLLArZl+AV/AdmrD1/pD1FlmqM+FKbtnONnIwbHtgetFUCV/SRi1q4D49aTlw==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.7.11': resolution: {integrity: sha512-tYkGIx8hjWPh4zcn17jLEHU8YMmdP2obRTGkdaB3BguGHh31VCS3ywqC4QjTODjmhhNyZYkj/1Dz/+0kKvg9YA==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.7.11': resolution: {integrity: sha512-6F328QIUev29vcZeRX6v6oqKxfUoGwIIAhWGD8wSysnBYFY0nivp25jdWmAb1GildbCCaQvOKEhCok7YfWkj4Q==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.7.11': resolution: {integrity: sha512-NqhWmiGJGdzbZbeucPZIG9Iav4lyYLCarEnxAceguMx9qlpeEF7ENqYKOwB8Zqk7/CeuYMEcLYMaW2li6HyDzQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.7.11': resolution: {integrity: sha512-J2RPIFKMdTrLtBdfR1cUMKl8Gcy05nlQ+bEs/6al7EdWLk9cs3tnDREHZ7mV9uGbeghpjo4i8neNZNx3PYUY9w==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.7.11': resolution: {integrity: sha512-bDpGRerHvvHdhun7MmFUNDpMiYcJSqWckwAVVRTJf8F+RyqYJOp/mx04PDc7DhpNPeWdnTMu91oZRMV+gGaVcQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.7.11': resolution: {integrity: sha512-G9U7bVmylzRLma3cK39RBm3guoD1HOvY4o0NS4JNm37AD0lS7/xyMt7kn0JejYyc0Im8J+rH69/dXGM9DAJcSQ==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.7.11': resolution: {integrity: sha512-7qL20SBKomekSunm7M9Fe5L93bFbn+FbHiGJbfTlp0RKhPVoJDP73vOxf1QrmJHyDPECsGWPFnKa/f8fO2FsHw==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.7.11': resolution: {integrity: sha512-jisvIva8MidjI+B1lFRZZMfCPaCISePgTyR60wNT1MeQvIh5Ksa0G3gvI+Iqyj3jqYbvOHByenpa5eDGcSdoSg==} @@ -2424,6 +2452,11 @@ packages: resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} engines: {node: '>=0.8', npm: '>=1.3.7'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + ignore@5.3.1: resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} engines: {node: '>= 4'} @@ -6404,6 +6437,8 @@ snapshots: jsprim: 1.4.2 sshpk: 1.18.0 + husky@9.1.7: {} + ignore@5.3.1: {} ignore@7.0.5: {} diff --git a/client/src/app/leader/postcard/page.tsx b/client/src/app/leader/postcard/page.tsx index 0ac3107b..7149ba4b 100644 --- a/client/src/app/leader/postcard/page.tsx +++ b/client/src/app/leader/postcard/page.tsx @@ -5,7 +5,7 @@ import { Community } from "@server/entity/community" import { User } from "@server/entity/user" import { get } from "@/config/api" import { useEffect, useState } from "react" -import Header from "@/app/leader/components/HeaderList" +import Header from "@/components/Header" import { useRouter } from "next/navigation" import axios from "@/config/axios" import { useNotification } from "@/hooks/useNotification" From f8d7df61d3c3704e71916ab28f0102bc235e85b9 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 11 May 2026 18:25:56 +0900 Subject: [PATCH 7/9] =?UTF-8?q?update:=20=EB=B0=B1=EC=97=85=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4=2060=EC=9D=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/scripts/backup-db.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/scripts/backup-db.sh b/server/scripts/backup-db.sh index 8a1b025e..ecc9286d 100755 --- a/server/scripts/backup-db.sh +++ b/server/scripts/backup-db.sh @@ -81,8 +81,8 @@ if [ $? -eq 0 ]; then gzip $BACKUP_FILE echo "✅ 압축 완료: ${BACKUP_FILE}.gz" - # 30일 이상 된 백업 파일 삭제 - find $BACKUP_DIR -name "*.sql.gz" -mtime +30 -delete + # 60일 이상 된 백업 파일 삭제 + find $BACKUP_DIR -name "*.sql.gz" -mtime +60 -delete echo "✅ 오래된 백업 파일 정리 완료" else echo "❌ 백업 실패!" From 903e86904abafac4b3da6b8766759e1a77caa69b Mon Sep 17 00:00:00 2001 From: iubns Date: Thu, 14 May 2026 19:47:16 +0900 Subject: [PATCH 8/9] =?UTF-8?q?update:=20=EA=B6=8C=ED=95=9C=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=82=AC=EC=9A=A9=EC=9E=90=EC=97=90=EA=B2=8C=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EB=B3=B4=EC=9D=B4=EB=8A=94=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/leader/all-attendance/page.tsx | 2 +- client/src/components/Header/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/app/leader/all-attendance/page.tsx b/client/src/app/leader/all-attendance/page.tsx index eb7951b1..1786f4e1 100644 --- a/client/src/app/leader/all-attendance/page.tsx +++ b/client/src/app/leader/all-attendance/page.tsx @@ -41,7 +41,7 @@ export default function AttendanceAdminPage() { if (!authUserData) return if (!authUserData.role.VillageLeader) { error("접근 권한이 없습니다.") - push("/leader") + push("/") return } fetchCommunities() diff --git a/client/src/components/Header/index.tsx b/client/src/components/Header/index.tsx index 19d51816..844b42c1 100644 --- a/client/src/components/Header/index.tsx +++ b/client/src/components/Header/index.tsx @@ -64,7 +64,7 @@ export default function Header() { type: "menu", },*/ - if (authUserData?.role.VillageLeader || authUserData?.role.Admin) { + if (authUserData?.role.VillageLeader) { DrawerItems.push({ title: "전체 출석 조회", icon: , From 9d512f7571b633023ad2f210eb83e7bac33a875a Mon Sep 17 00:00:00 2001 From: iubns Date: Thu, 14 May 2026 20:13:42 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- server/src/entity/community/board.ts | 62 +++++++++ server/src/entity/community/comment.ts | 46 +++++++ server/src/entity/community/freePost.ts | 7 + server/src/entity/community/post.ts | 69 ++++++++++ server/src/entity/community/qnaPost.ts | 17 +++ server/src/entity/community/reaction.ts | 32 +++++ .../1780568400000-AddCommunityBoards.ts | 126 ++++++++++++++++++ server/src/model/dataSource.ts | 13 ++ 8 files changed, 372 insertions(+) create mode 100644 server/src/entity/community/board.ts create mode 100644 server/src/entity/community/comment.ts create mode 100644 server/src/entity/community/freePost.ts create mode 100644 server/src/entity/community/post.ts create mode 100644 server/src/entity/community/qnaPost.ts create mode 100644 server/src/entity/community/reaction.ts create mode 100644 server/src/migration/1780568400000-AddCommunityBoards.ts diff --git a/server/src/entity/community/board.ts b/server/src/entity/community/board.ts new file mode 100644 index 00000000..98f5c296 --- /dev/null +++ b/server/src/entity/community/board.ts @@ -0,0 +1,62 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToMany, + JoinTable, + ManyToOne, +} from "typeorm" +import { User } from "../user" + +export enum BoardVisibility { + PUBLIC = "public", + MEMBERS = "members", + PRIVATE = "private", +} + +@Entity() +export class Board { + @PrimaryGeneratedColumn("uuid") + id!: string + + @Column() + name!: string + + @Column({ unique: true }) + slug!: string + + @Column({ nullable: true, type: "text" }) + description?: string + + @Column({ + type: "enum", + enum: BoardVisibility, + default: BoardVisibility.PUBLIC, + }) + visibility!: BoardVisibility + + // Flexible JSON settings for custom fields, templates, or admin flags + @Column({ type: "json", nullable: true }) + settings?: Record + + @ManyToMany(() => User, { nullable: true }) + @JoinTable({ name: "board_moderators" }) + moderators?: User[] + + @ManyToOne(() => User, { nullable: true }) + createdBy?: User | null + + @CreateDateColumn({ + type: "timestamp", + default: () => "CURRENT_TIMESTAMP(6)", + }) + createdAt!: Date + + @UpdateDateColumn({ + type: "timestamp", + default: () => "CURRENT_TIMESTAMP(6)", + }) + updatedAt!: Date +} diff --git a/server/src/entity/community/comment.ts b/server/src/entity/community/comment.ts new file mode 100644 index 00000000..44457b3a --- /dev/null +++ b/server/src/entity/community/comment.ts @@ -0,0 +1,46 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + CreateDateColumn, + DeleteDateColumn, +} from "typeorm" +import { Post } from "./post" +import { User } from "../user" + +@Entity() +export class Comment { + @PrimaryGeneratedColumn("uuid") + id!: string + + @ManyToOne(() => Post, (post) => post.comments, { nullable: false }) + post!: Post + + @ManyToOne(() => Comment, (comment) => comment.children, { + nullable: true, + }) + parent?: Comment | null + + @OneToMany(() => Comment, (comment) => comment.parent) + children?: Comment[] + + @ManyToOne(() => User, { nullable: true }) + author?: User | null + + @Column({ type: "text" }) + content!: string + + @Column({ default: false }) + isAnonymous!: boolean + + @CreateDateColumn({ + type: "timestamp", + default: () => "CURRENT_TIMESTAMP(6)", + }) + createdAt!: Date + + @DeleteDateColumn({ type: "timestamp", nullable: true }) + deletedAt?: Date | null +} diff --git a/server/src/entity/community/freePost.ts b/server/src/entity/community/freePost.ts new file mode 100644 index 00000000..8d80fa3f --- /dev/null +++ b/server/src/entity/community/freePost.ts @@ -0,0 +1,7 @@ +import { ChildEntity } from "typeorm" +import { Post, PostType } from "./post" + +@ChildEntity(PostType.FREE) +export class FreePost extends Post { + // FreePost 특화 필드 추가 가능 (현재는 공통 필드만 사용) +} diff --git a/server/src/entity/community/post.ts b/server/src/entity/community/post.ts new file mode 100644 index 00000000..ab0c39e0 --- /dev/null +++ b/server/src/entity/community/post.ts @@ -0,0 +1,69 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + ManyToOne, + OneToMany, + TableInheritance, + ChildEntity, +} from "typeorm" +import { User } from "../user" +import { Comment } from "./comment" +import { Reaction } from "./reaction" + +export enum PostType { + FREE = "free", + QNA = "qna", +} + +@Entity() +@TableInheritance({ + column: { type: "varchar", name: "type", default: PostType.FREE }, +}) +export class Post { + @PrimaryGeneratedColumn("uuid") + id!: string + + @Column({ type: "varchar", length: 50 }) + type!: PostType + + @ManyToOne(() => User, { nullable: true }) + author?: User | null + + @Column({ nullable: true }) + title?: string + + @Column({ type: "text", nullable: true }) + content?: string + + @Column({ default: false }) + isAnonymous!: boolean + + @OneToMany(() => Comment, (comment) => comment.post, { + cascade: ["insert", "update"], + }) + comments?: Comment[] + + @OneToMany(() => Reaction, (reaction) => reaction.post, { + cascade: ["insert", "update"], + }) + reactions?: Reaction[] + + @CreateDateColumn({ + type: "timestamp", + default: () => "CURRENT_TIMESTAMP(6)", + }) + createdAt!: Date + + @UpdateDateColumn({ + type: "timestamp", + default: () => "CURRENT_TIMESTAMP(6)", + }) + updatedAt!: Date + + @DeleteDateColumn({ type: "timestamp", nullable: true }) + deletedAt?: Date | null +} diff --git a/server/src/entity/community/qnaPost.ts b/server/src/entity/community/qnaPost.ts new file mode 100644 index 00000000..37f1a738 --- /dev/null +++ b/server/src/entity/community/qnaPost.ts @@ -0,0 +1,17 @@ +import { ChildEntity, Column, ManyToOne } from "typeorm" +import { User } from "../user" +import { Post, PostType } from "./post" + +@ChildEntity(PostType.QNA) +export class QnaPost extends Post { + // Admin's answer + @Column({ type: "text", nullable: true }) + answer?: string | null + + @ManyToOne(() => User, { nullable: true }) + answeredBy?: User | null + + // If true, answer is visible to everyone; otherwise only to the asker and admins + @Column({ default: false }) + answerPublic!: boolean +} diff --git a/server/src/entity/community/reaction.ts b/server/src/entity/community/reaction.ts new file mode 100644 index 00000000..ccb79836 --- /dev/null +++ b/server/src/entity/community/reaction.ts @@ -0,0 +1,32 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + Index, +} from "typeorm" +import { Post } from "./post" +import { User } from "../user" + +@Entity() +@Index(["post", "user", "type"], { unique: true }) +export class Reaction { + @PrimaryGeneratedColumn("uuid") + id!: string + + @ManyToOne(() => Post, (post) => post.reactions, { nullable: false }) + post!: Post + + @ManyToOne(() => User, { nullable: false }) + user!: User + + @Column() + type!: string + + @CreateDateColumn({ + type: "timestamp", + default: () => "CURRENT_TIMESTAMP(6)", + }) + createdAt!: Date +} diff --git a/server/src/migration/1780568400000-AddCommunityBoards.ts b/server/src/migration/1780568400000-AddCommunityBoards.ts new file mode 100644 index 00000000..48f036ed --- /dev/null +++ b/server/src/migration/1780568400000-AddCommunityBoards.ts @@ -0,0 +1,126 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class AddCommunityBoards1780568400000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Create Board table + await queryRunner.query(` + CREATE TABLE \`board\` ( + \`id\` varchar(36) NOT NULL, + \`name\` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, + \`slug\` varchar(255) COLLATE utf8mb4_general_ci NOT NULL UNIQUE, + \`description\` text COLLATE utf8mb4_general_ci NULL, + \`visibility\` enum ('public', 'members', 'private') COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'public', + \`settings\` json NULL, + \`createdById\` varchar(36) COLLATE utf8mb4_general_ci NULL, + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`updatedAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (\`id\`), + INDEX \`IDX_board_createdById\` (\`createdById\`), + CONSTRAINT \`FK_board_createdBy\` FOREIGN KEY (\`createdById\`) REFERENCES \`user\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + + // Create board_moderators join table + await queryRunner.query(` + CREATE TABLE \`board_moderators\` ( + \`boardId\` varchar(36) NOT NULL, + \`userId\` varchar(36) NOT NULL, + PRIMARY KEY (\`boardId\`, \`userId\`), + INDEX \`IDX_board_moderators_userId\` (\`userId\`), + CONSTRAINT \`FK_board_moderators_boardId\` FOREIGN KEY (\`boardId\`) REFERENCES \`board\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT \`FK_board_moderators_userId\` FOREIGN KEY (\`userId\`) REFERENCES \`user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + + // Create Post table with Table Inheritance + await queryRunner.query(` + CREATE TABLE \`post\` ( + \`id\` varchar(36) NOT NULL, + \`type\` varchar(50) COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'free', + \`authorId\` varchar(36) COLLATE utf8mb4_general_ci NULL, + \`title\` varchar(255) COLLATE utf8mb4_general_ci NULL, + \`content\` text COLLATE utf8mb4_general_ci NULL, + \`isAnonymous\` boolean NOT NULL DEFAULT false, + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`updatedAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`deletedAt\` timestamp NULL, + PRIMARY KEY (\`id\`), + INDEX \`IDX_post_type\` (\`type\`), + INDEX \`IDX_post_authorId\` (\`authorId\`), + INDEX \`IDX_post_deletedAt\` (\`deletedAt\`), + CONSTRAINT \`FK_post_author\` FOREIGN KEY (\`authorId\`) REFERENCES \`user\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + + // Create free_post table + await queryRunner.query(` + CREATE TABLE \`free_post\` ( + \`id\` varchar(36) NOT NULL, + PRIMARY KEY (\`id\`), + CONSTRAINT \`FK_free_post_id\` FOREIGN KEY (\`id\`) REFERENCES \`post\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + + // Create qna_post table + await queryRunner.query(` + CREATE TABLE \`qna_post\` ( + \`id\` varchar(36) NOT NULL, + \`answer\` text COLLATE utf8mb4_general_ci NULL, + \`answeredById\` varchar(36) COLLATE utf8mb4_general_ci NULL, + \`answerPublic\` boolean NOT NULL DEFAULT false, + PRIMARY KEY (\`id\`), + INDEX \`IDX_qna_post_answeredById\` (\`answeredById\`), + CONSTRAINT \`FK_qna_post_id\` FOREIGN KEY (\`id\`) REFERENCES \`post\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT \`FK_qna_post_answeredBy\` FOREIGN KEY (\`answeredById\`) REFERENCES \`user\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + + // Create Comment table (shared by both FreePost and QnaPost) + await queryRunner.query(` + CREATE TABLE \`comment\` ( + \`id\` varchar(36) NOT NULL, + \`postId\` varchar(36) COLLATE utf8mb4_general_ci NOT NULL, + \`parentId\` varchar(36) COLLATE utf8mb4_general_ci NULL, + \`authorId\` varchar(36) COLLATE utf8mb4_general_ci NULL, + \`content\` text COLLATE utf8mb4_general_ci NOT NULL, + \`isAnonymous\` boolean NOT NULL DEFAULT false, + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`deletedAt\` timestamp NULL, + PRIMARY KEY (\`id\`), + INDEX \`IDX_comment_postId\` (\`postId\`), + INDEX \`IDX_comment_parentId\` (\`parentId\`), + INDEX \`IDX_comment_authorId\` (\`authorId\`), + INDEX \`IDX_comment_deletedAt\` (\`deletedAt\`), + CONSTRAINT \`FK_comment_post\` FOREIGN KEY (\`postId\`) REFERENCES \`post\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT \`FK_comment_parent\` FOREIGN KEY (\`parentId\`) REFERENCES \`comment\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION, + CONSTRAINT \`FK_comment_author\` FOREIGN KEY (\`authorId\`) REFERENCES \`user\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + + // Create Reaction table (shared by both FreePost and QnaPost) + await queryRunner.query(` + CREATE TABLE \`reaction\` ( + \`id\` varchar(36) NOT NULL, + \`postId\` varchar(36) COLLATE utf8mb4_general_ci NOT NULL, + \`userId\` varchar(36) COLLATE utf8mb4_general_ci NOT NULL, + \`type\` varchar(50) COLLATE utf8mb4_general_ci NOT NULL, + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (\`id\`), + UNIQUE INDEX \`IDX_reaction_post_user_type\` (\`postId\`, \`userId\`, \`type\`), + INDEX \`IDX_reaction_userId\` (\`userId\`), + CONSTRAINT \`FK_reaction_post\` FOREIGN KEY (\`postId\`) REFERENCES \`post\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT \`FK_reaction_user\` FOREIGN KEY (\`userId\`) REFERENCES \`user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS \`reaction\``) + await queryRunner.query(`DROP TABLE IF EXISTS \`comment\``) + await queryRunner.query(`DROP TABLE IF EXISTS \`qna_post\``) + await queryRunner.query(`DROP TABLE IF EXISTS \`free_post\``) + await queryRunner.query(`DROP TABLE IF EXISTS \`post\``) + await queryRunner.query(`DROP TABLE IF EXISTS \`board_moderators\``) + await queryRunner.query(`DROP TABLE IF EXISTS \`board\``) + } +} diff --git a/server/src/model/dataSource.ts b/server/src/model/dataSource.ts index 23993263..0528fb7d 100644 --- a/server/src/model/dataSource.ts +++ b/server/src/model/dataSource.ts @@ -17,6 +17,12 @@ import { NewcomerEducation } from "../entity/newcomer/newcomerEducation" import { NewcomerManager } from "../entity/newcomer/newcomerManager" import { Link } from "../entity/link" import { LinkClick } from "../entity/linkClick" +import { Post } from "../entity/community/post" +import { FreePost } from "../entity/community/freePost" +import { QnaPost } from "../entity/community/qnaPost" +import { Comment } from "../entity/community/comment" +import { Reaction } from "../entity/community/reaction" +import { Board } from "../entity/community/board" const dataSource = new DataSource(require("../../ormconfig.js")) @@ -42,4 +48,11 @@ export const newcomerManagerDatabase = dataSource.getRepository(NewcomerManager) export const linkDatabase = dataSource.getRepository(Link) export const linkClickDatabase = dataSource.getRepository(LinkClick) +export const freePostDatabase = dataSource.getRepository(FreePost) +export const qnaPostDatabase = dataSource.getRepository(QnaPost) +export const postDatabase = dataSource.getRepository(Post) +export const commentDatabase = dataSource.getRepository(Comment) +export const reactionDatabase = dataSource.getRepository(Reaction) +export const boardDatabase = dataSource.getRepository(Board) + export default dataSource