diff --git a/client/package.json b/client/package.json
index c4690f7..e007a2b 100644
--- a/client/package.json
+++ b/client/package.json
@@ -23,6 +23,7 @@
"jsbarcode": "^3.11.6",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
+ "marked": "^17.0.1",
"mui": "^1.0.0",
"next": "15.3.3",
"quagga": "0.12.1",
diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml
index 40ee95a..daa1510 100644
--- a/client/pnpm-lock.yaml
+++ b/client/pnpm-lock.yaml
@@ -47,6 +47,9 @@ importers:
lodash:
specifier: ^4.17.21
version: 4.17.21
+ marked:
+ specifier: ^17.0.1
+ version: 17.0.1
mui:
specifier: ^1.0.0
version: 1.0.0
@@ -2756,6 +2759,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ marked@17.0.1:
+ resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -5945,7 +5953,7 @@ snapshots:
'@typescript-eslint/parser': 5.62.0(eslint@9.28.0)(typescript@5.8.3)
eslint: 9.28.0
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.28.0)
eslint-plugin-react: 7.37.5(eslint@9.28.0)
@@ -5965,7 +5973,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.0
@@ -5980,14 +5988,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0))(eslint@9.28.0):
+ eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 5.62.0(eslint@9.28.0)(typescript@5.8.3)
eslint: 9.28.0
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0)
transitivePeerDependencies:
- supports-color
@@ -6002,7 +6010,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.28.0
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0))(eslint@9.28.0)
+ eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -6712,6 +6720,8 @@ snapshots:
dependencies:
yallist: 3.1.1
+ marked@17.0.1: {}
+
math-intrinsics@1.1.0: {}
mdn-data@2.0.28: {}
diff --git a/client/src/app/admin/ai/LeftList.tsx b/client/src/app/admin/ai/LeftList.tsx
new file mode 100644
index 0000000..fb5c87e
--- /dev/null
+++ b/client/src/app/admin/ai/LeftList.tsx
@@ -0,0 +1,111 @@
+"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
new file mode 100644
index 0000000..f399c6c
--- /dev/null
+++ b/client/src/app/admin/ai/chat/Chat.tsx
@@ -0,0 +1,138 @@
+"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
new file mode 100644
index 0000000..6f4aa10
--- /dev/null
+++ b/client/src/app/admin/ai/chat/page.tsx
@@ -0,0 +1,105 @@
+"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
new file mode 100644
index 0000000..bd15f4e
--- /dev/null
+++ b/client/src/app/admin/ai/chat/useAiChat.ts
@@ -0,0 +1,96 @@
+"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 42e3a98..b00a8b9 100644
--- a/client/src/app/admin/components/Header/index.tsx
+++ b/client/src/app/admin/components/Header/index.tsx
@@ -9,6 +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"
export default function AdminHeader() {
const { push } = useRouter()
@@ -62,6 +63,12 @@ export default function AdminHeader() {
path: "/admin/soon/attendance",
type: "menu",
},
+ {
+ title: "AI로 데이터 분석",
+ icon: ,
+ path: "/admin/ai/chat",
+ type: "menu",
+ },
{
type: "divider",
},
diff --git a/client/src/app/admin/layout.tsx b/client/src/app/admin/layout.tsx
index bca6615..849ff94 100644
--- a/client/src/app/admin/layout.tsx
+++ b/client/src/app/admin/layout.tsx
@@ -1,6 +1,7 @@
"use client"
import Header from "@/app/admin/components/Header"
+import { Stack } from "@mui/material"
export default function AdminLayout({
children,
@@ -8,9 +9,11 @@ export default function AdminLayout({
children: React.ReactNode
}) {
return (
- <>
+
- {children}
- >
+
+ {children}
+
+
)
}
diff --git a/client/src/app/admin/soon/page.tsx b/client/src/app/admin/soon/page.tsx
index d247696..6a126eb 100644
--- a/client/src/app/admin/soon/page.tsx
+++ b/client/src/app/admin/soon/page.tsx
@@ -150,7 +150,7 @@ export default function Soon() {
const filteredUsers = orderingUserList()
return (
-
+
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
new file mode 100644
index 0000000..e1416ff
--- /dev/null
+++ b/server/src/entity/ai/aiChatRoom.ts
@@ -0,0 +1,29 @@
+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/1765814621862-WorshipContest.ts b/server/src/migration/1765814621862-WorshipContest.ts
index 14271fd..5a48a38 100644
--- a/server/src/migration/1765814621862-WorshipContest.ts
+++ b/server/src/migration/1765814621862-WorshipContest.ts
@@ -5,7 +5,7 @@ export class WorshipContest1765814621862 implements MigrationInterface {
await queryRunner.query(`
CREATE TABLE \`worship_contest\` (
\`id\` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
- \`voteUserId\` CHAR(36),
+ \`voteUserId\` VARCHAR(36) NOT NULL,
\`firstCommunity\` VARCHAR(255) NOT NULL,
\`secondCommunity\` VARCHAR(255) NOT NULL,
\`thirdCommunity\` VARCHAR(255) NOT NULL,
diff --git a/server/src/migration/1768640534507-CreateAiChat.ts b/server/src/migration/1768640534507-CreateAiChat.ts
new file mode 100644
index 0000000..24c61d1
--- /dev/null
+++ b/server/src/migration/1768640534507-CreateAiChat.ts
@@ -0,0 +1,32 @@
+import { MigrationInterface, QueryRunner } from "typeorm"
+
+export class CreateAiChat1768640534507 implements MigrationInterface {
+ public async up(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
+ `)
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP TABLE \`ai_chat\``)
+ await queryRunner.query(`DROP TABLE \`ai_chat_room\``)
+ }
+}
diff --git a/server/src/migration/1768657178669-AddCommentInAllColumn.ts b/server/src/migration/1768657178669-AddCommentInAllColumn.ts
new file mode 100644
index 0000000..39d80d0
--- /dev/null
+++ b/server/src/migration/1768657178669-AddCommentInAllColumn.ts
@@ -0,0 +1,303 @@
+import { MigrationInterface, QueryRunner } from "typeorm"
+
+export class AddCommentInAllColumn1768657178669 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ // FK Check 비활성화
+ await queryRunner.query("SET FOREIGN_KEY_CHECKS = 0")
+
+ // ==========================================
+ // 1. User Table (사용자)
+ // ==========================================
+ await queryRunner.query(
+ `ALTER TABLE \`user\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '사용자 고유 ID (UUID)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`user\` MODIFY COLUMN \`kakaoId\` varchar(255) NULL COMMENT '카카오 계정 고유 ID (로그인 식별용)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`user\` MODIFY COLUMN \`name\` varchar(255) NULL COMMENT '사용자 실명'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`user\` MODIFY COLUMN \`yearOfBirth\` int NULL COMMENT '출생년도 (YYYY 형식)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`user\` MODIFY COLUMN \`gender\` varchar(255) NULL COMMENT '성별 (man: 남성, woman: 여성)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`user\` MODIFY COLUMN \`phone\` varchar(255) NULL COMMENT '전화번호 (하이픈 없이 숫자만 or 하이픈 포함)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`user\` MODIFY COLUMN \`etc\` varchar(255) NULL COMMENT '사용자 관련 비고/특이사항'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`user\` MODIFY COLUMN \`token\` varchar(255) NULL COMMENT '자체 인증 토큰'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`user\` MODIFY COLUMN \`expire\` datetime NULL COMMENT '인증 토큰 만료 시간'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`user\` MODIFY COLUMN \`isSuperUser\` tinyint NOT NULL DEFAULT 0 COMMENT '시스템 최고 관리자 권한 여부 (1: 관리자, 0: 일반)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`user\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '계정 생성 일시'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`user\` MODIFY COLUMN \`deletedAt\` timestamp(6) NULL COMMENT '계정 삭제 일시 (Soft Delete, NULL이면 활성 계정)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`user\` MODIFY COLUMN \`profile\` int NOT NULL DEFAULT 0 COMMENT '프로필 이미지 식별자 (ID)'`,
+ )
+ // await queryRunner.query(`ALTER TABLE \`user\` MODIFY COLUMN \`communityId\` int NULL COMMENT '현재 소속된 공동체/순 ID (FK)'`);
+
+ // ==========================================
+ // 2. Community Table (공동체/조직)
+ // ==========================================
+ await queryRunner.query(
+ `ALTER TABLE \`community\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '공동체 고유 ID'`,
+ )
+ // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`parentId\` int NULL COMMENT '상위 조직 ID (FK, NULL이면 최상위 조직)'`);
+ await queryRunner.query(
+ `ALTER TABLE \`community\` MODIFY COLUMN \`name\` varchar(255) NOT NULL COMMENT '공동체 이름 (예: 무슨무슨 마을, 무슨무슨 다락방, 다락방이 최하위 조직으로 상위는 마을임)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`community\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '생성 일시'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`community\` MODIFY COLUMN \`lastModifiedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '마지막 정보 수정 일시'`,
+ )
+ // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`leaderId\` varchar(36) NULL COMMENT '리더(순장/마을장) 사용자 ID (FK)'`);
+ // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`deputyLeaderId\` varchar(36) NULL COMMENT '부리더(부순장) 사용자 ID (FK)'`);
+ await queryRunner.query(
+ `ALTER TABLE \`community\` MODIFY COLUMN \`x\` int NOT NULL DEFAULT 0 COMMENT '조직도 시각화용 X 좌표'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`community\` MODIFY COLUMN \`y\` int NOT NULL DEFAULT 0 COMMENT '조직도 시각화용 Y 좌표'`,
+ )
+
+ // ==========================================
+ // 3. Permission Table (권한 관리)
+ // ==========================================
+ await queryRunner.query(
+ `ALTER TABLE \`permission\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '권한 레코드 ID'`,
+ )
+ // await queryRunner.query(`ALTER TABLE \`permission\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '권한이 부여된 사용자 ID (FK)'`);
+ await queryRunner.query(
+ `ALTER TABLE \`permission\` MODIFY COLUMN \`permissionType\` int NOT NULL COMMENT '부여된 권한 종류 (0: superUser, 1: admin, 2: userList 등 Enum 참조)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`permission\` MODIFY COLUMN \`have\` tinyint NOT NULL COMMENT '권한 보유 여부 (1: 있음, 0: 없음)'`,
+ )
+
+ // ==========================================
+ // 4. WorshipSchedule Table (예배 일정)
+ // ==========================================
+ await queryRunner.query(
+ `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '예배 일정 ID'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`kind\` int NOT NULL COMMENT '예배 종류 (1: 주일예배, 2: 금요예배, 3: 기타)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`date\` varchar(255) NOT NULL COMMENT '예배 날짜 (YYYY-MM-DD 문자열 형식)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`canEdit\` tinyint NOT NULL DEFAULT 1 COMMENT '리더의 출석 입력 가능 여부 (1: 가능, 0: 마감)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`isVisible\` tinyint NOT NULL DEFAULT 1 COMMENT '앱 내 일정 노출 여부 (1: 보임, 0: 숨김)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '일정 생성 일시'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`lastModifiedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '마지막 수정 일시'`,
+ )
+
+ // ==========================================
+ // 5. AttendData Table (출석 기록)
+ // ==========================================
+ await queryRunner.query(
+ `ALTER TABLE \`attend_data\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '출석 기록 고유 ID'`,
+ )
+ // await queryRunner.query(`ALTER TABLE \`attend_data\` MODIFY COLUMN \`worshipScheduleId\` int NULL COMMENT '대상 예배 일정 ID (FK)'`);
+ // await queryRunner.query(`ALTER TABLE \`attend_data\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '출석 대상 사용자 ID (FK)'`);
+ await queryRunner.query(
+ `ALTER TABLE \`attend_data\` MODIFY COLUMN \`isAttend\` varchar(255) NOT NULL COMMENT '출석 상태 (ATTEND: 출석, ABSENT: 결석, ETC: 늦참/기타)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`attend_data\` MODIFY COLUMN \`memo\` varchar(255) NOT NULL DEFAULT '' COMMENT '사유 또는 비고 사항'`,
+ )
+
+ // ==========================================
+ // 6. RetreatAttend Table (수련회 참가 정보)
+ // ==========================================
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '수련회 참가 신청서 ID'`,
+ )
+ // await queryRunner.query(`ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '신청자 ID (FK)'`);
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`groupNumber\` int NOT NULL DEFAULT 0 COMMENT '배정된 조 번호 (0이면 미배정)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`roomNumber\` int NULL COMMENT '배정된 숙소/방 번호'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`memo\` text NULL COMMENT '관리자용 메모'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isDeposited\` varchar(255) NOT NULL DEFAULT 'none' COMMENT '회비 입금 상태 (none: 미입금, student: 학생회비, business: 직장인회비, half: 부분참석회비)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`howToGo\` int NULL COMMENT '가는 편 이동 수단 (1: 같이가기, 2: 자차(단독), 3: 자차(동승), 4: 얻어타기, 5: 따로가기 등)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`howToBack\` int NULL COMMENT '오는 편 이동 수단 (옵션 위와 동일)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isCanceled\` tinyint NOT NULL DEFAULT 0 COMMENT '참가 취소 여부 (1: 취소됨)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`etc\` varchar(255) NULL COMMENT '신청서 기타란 내용'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`currentStatus\` int NOT NULL DEFAULT 0 COMMENT '참가 현황 상태 (0: null, 1: 교회도착, 2: 수련회장도착 등)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`attendanceNumber\` int NOT NULL DEFAULT 0 COMMENT '접수 번호 (순서대로 발급)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`postcardContent\` text NULL COMMENT '수련회 엽서 내용'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isWorker\` tinyint NOT NULL DEFAULT 1 COMMENT '직장인 여부 (1: 직장인, 0: 학생) - 회비 구분에 사용'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isHalf\` tinyint NOT NULL DEFAULT 0 COMMENT '부분 참석 여부 (1: 토요일 저녁 이후 참석/부분참석, 0: 전체참석)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '신청서 작성 일시'`,
+ )
+
+ // ==========================================
+ // 7. InOutInfo Table (입퇴실 상세 이동 정보)
+ // ==========================================
+ await queryRunner.query(
+ `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '이동 정보 ID'`,
+ )
+ // await queryRunner.query(`ALTER TABLE \`in_out_info\` MODIFY COLUMN \`retreatAttendId\` varchar(36) NULL COMMENT '수련회 참가 신청 ID (FK)'`);
+ await queryRunner.query(
+ `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`day\` int NOT NULL COMMENT '수련회 1, 2, 3일차 구분 (1=금, 2=토, 3=일)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`time\` varchar(255) NOT NULL COMMENT '예상 이동 시각 (HH:MM 문자열)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`inOutType\` varchar(255) NOT NULL COMMENT '이동 방향 (IN: 수련회장으로 감, OUT: 수련회장에서 나옴, none: 없음)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`position\` varchar(255) NOT NULL COMMENT '출발지 또는 목적지 상세 (예: 교회, 집 등)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`howToMove\` int NOT NULL COMMENT '상세 이동 수단 (Enum 참조)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`autoCreated\` tinyint NOT NULL DEFAULT 0 COMMENT '시스템 자동 생성 여부'`,
+ )
+ // await queryRunner.query(`ALTER TABLE \`in_out_info\` MODIFY COLUMN \`rideCarInfoId\` int NULL COMMENT '카풀 시 탑승한 차량(운전자)의 이동 정보 ID (FK)'`);
+
+ // ==========================================
+ // 8. SharingText Table (나눔 - 텍스트)
+ // ==========================================
+ await queryRunner.query(
+ `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '은혜 나눔(글) ID'`,
+ )
+ // await queryRunner.query(`ALTER TABLE \`sharing_text\` MODIFY COLUMN \`writerId\` varchar(36) NULL COMMENT '작성자 User ID (FK)'`);
+ await queryRunner.query(
+ `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`content\` text NOT NULL COMMENT '나눔 본문 내용'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`visible\` tinyint NOT NULL DEFAULT 1 COMMENT '공개 여부 (1: 공개, 0: 비공개)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '작성 일시'`,
+ )
+
+ // ==========================================
+ // 9. SharingImage Table (나눔 - 이미지)
+ // ==========================================
+ await queryRunner.query(
+ `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '은혜 나눔(사진) ID'`,
+ )
+ // await queryRunner.query(`ALTER TABLE \`sharing_image\` MODIFY COLUMN \`writerId\` varchar(36) NULL COMMENT '업로더 User ID (FK)'`);
+ await queryRunner.query(
+ `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`url\` text NOT NULL COMMENT 'S3 등 스토리지 이미지 경로'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`visible\` tinyint NOT NULL DEFAULT 1 COMMENT '공개 여부'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`tags\` text NULL COMMENT '이미지 태그 (콤마로 구분되거나 JSON)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '업로드 일시'`,
+ )
+
+ // ==========================================
+ // 10. WorshipContest Table (워십 콘테스트 투표)
+ // ==========================================
+ await queryRunner.query(
+ `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '투표 기록 ID'`,
+ )
+ // await queryRunner.query(`ALTER TABLE \`worship_contest\` MODIFY COLUMN \`voteUserId\` varchar(36) NULL COMMENT '투표한 사용자 ID (FK)'`);
+ await queryRunner.query(
+ `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`firstCommunity\` varchar(255) NOT NULL COMMENT '1순위 투표 팀 이름'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`secondCommunity\` varchar(255) NOT NULL COMMENT '2순위 투표 팀 이름'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`thirdCommunity\` varchar(255) NOT NULL COMMENT '3순위 투표 팀 이름'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`term\` int NOT NULL COMMENT '투표 부문/회차 (예: 1부, 2부)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '투표 일시'`,
+ )
+
+ // ==========================================
+ // 11. AIChatRoom Table (AI 챗봇 채팅방)
+ // ==========================================
+ await queryRunner.query(
+ `ALTER TABLE \`ai_chat_room\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '채팅방 고유 ID (UUID)'`,
+ )
+ // await queryRunner.query(`ALTER TABLE \`ai_chat_room\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '채팅방 소유 사용자 ID (FK)'`);
+ await queryRunner.query(
+ `ALTER TABLE \`ai_chat_room\` MODIFY COLUMN \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '채팅방 생성 일시'`,
+ )
+
+ // ==========================================
+ // 12. AIChat Table (AI 챗봇 메시지)
+ // ==========================================
+ await queryRunner.query(
+ `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '메시지 고유 ID (UUID)'`,
+ )
+ // await queryRunner.query(`ALTER TABLE \`ai_chat\` MODIFY COLUMN \`roomId\` varchar(36) NULL COMMENT '소속 채팅방 ID (FK)'`);
+ await queryRunner.query(
+ `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`type\` enum('user', 'ai', 'system') NOT NULL COMMENT '발화자 구분 (user: 사용자, ai: 챗봇, system: 시스템 프롬프트)'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`message\` text NOT NULL COMMENT '대화 내용'`,
+ )
+ await queryRunner.query(
+ `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '메시지 생성 일시'`,
+ )
+
+ // FK Check 활성화
+ await queryRunner.query("SET FOREIGN_KEY_CHECKS = 1")
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ // 생략 (롤백 시 코멘트 제거 로직이 필요하지만, 여기서는 생략함)
+ }
+}
diff --git a/server/src/model/ai.ts b/server/src/model/ai.ts
new file mode 100644
index 0000000..da87696
--- /dev/null
+++ b/server/src/model/ai.ts
@@ -0,0 +1,212 @@
+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 (!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 1999d5c..59c67b3 100644
--- a/server/src/model/dataSource.ts
+++ b/server/src/model/dataSource.ts
@@ -12,6 +12,8 @@ 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"
const dataSource = new DataSource(require("../../ormconfig.js"))
@@ -28,6 +30,9 @@ 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 default dataSource
diff --git a/server/src/routes/admin/ai.ts b/server/src/routes/admin/ai.ts
index 2b60d13..23c5e73 100644
--- a/server/src/routes/admin/ai.ts
+++ b/server/src/routes/admin/ai.ts
@@ -1,36 +1,99 @@
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) => {
- // AI 질문 처리 로직 구현
-
- const body = {
- model: "claude-sonnet-4-5-20250929",
- max_tokens: 1024,
- messages: [
- {
- role: "user",
- content: "Say this is a test!",
- },
- ],
+ const user = await getUserFromToken(req)
+ if (!user) {
+ res.status(401).json({ message: "Unauthorized" })
+ return
}
- 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 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")) {
+ console.log("query:", responseChat.message)
+ 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
}
- )
- const data = (await response.json()) as any
+ 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
+ }
- console.log("AI response data:", data)
- res.json(data.content)
+ res.json(chatRoom)
})
export default router