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/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}님
-
-
-
-
- {/* 새 채팅 만들기 버튼 */}
- }
- fullWidth
- variant="outlined"
- sx={{ mb: 1, borderRadius: 2, bgcolor: "white" }}
- onClick={() => {
- setSelectedChatRoomId("")
- setSelectedChatRoom(null)
- }}
- >
- 새 채팅
-
-
- {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/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 (
+
+ )
+}
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
new file mode 100644
index 00000000..dd74d8bb
--- /dev/null
+++ b/client/src/app/admin/link/page.tsx
@@ -0,0 +1,154 @@
+"use client"
+
+import {
+ Box,
+ Button,
+ Stack,
+ Typography,
+ Card,
+ CardContent,
+} from "@mui/material"
+import { useEffect, useState } from "react"
+import AddIcon from "@mui/icons-material/Add"
+import { useNotification } from "@/hooks/useNotification"
+import axios from "@/config/axios"
+import LinkIcon from "@mui/icons-material/Link"
+import LinkTable, { Link } from "./components/LinkTable"
+import LinkDialog from "./components/LinkDialog"
+
+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 (
+
+
+
+
+
+
+
+
+ 링크 관리
+
+
+ }
+ onClick={openAddDialog}
+ >
+ 링크 추가
+
+
+
+
+
+
+
+
+ setOpenDialog(false)}
+ onSave={saveLink}
+ onChange={setSelectedLink}
+ />
+
+ )
+}
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 (
+
+ )
+}
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/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..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/Header"
+import Header from "@/components/Header"
import { useRouter } from "next/navigation"
import axios from "@/config/axios"
import { useNotification } from "@/hooks/useNotification"
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/components/Header/index.tsx b/client/src/components/Header/index.tsx
index 7c5a954b..844b42c1 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) {
+ 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 (
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/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 "❌ 백업 실패!"
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/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/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/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/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/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/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..0528fb7d 100644
--- a/server/src/model/dataSource.ts
+++ b/server/src/model/dataSource.ts
@@ -12,11 +12,17 @@ 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"
+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"))
@@ -33,13 +39,20 @@ 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 =
dataSource.getRepository(NewcomerEducation)
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
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/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
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
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