diff --git a/client/public/kakao_default_profile.jpeg b/client/public/kakao_default_profile.jpeg deleted file mode 100644 index cccf18f9..00000000 Binary files a/client/public/kakao_default_profile.jpeg and /dev/null differ diff --git a/client/src/app/admin/community/boards/board.tsx b/client/src/app/admin/community/boards/board.tsx new file mode 100644 index 00000000..5bd24f25 --- /dev/null +++ b/client/src/app/admin/community/boards/board.tsx @@ -0,0 +1,140 @@ +"use client" + +import { + Card, + CardContent, + IconButton, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from "@mui/material" +import DeleteIcon from "@mui/icons-material/Delete" +import EditIcon from "@mui/icons-material/Edit" +import SaveIcon from "@mui/icons-material/Save" +import { deleteBoard } from "@/app/community/community.api" +import { useState } from "react" +import axios from "@/config/axios" + +export default function Board({ + board, + load, + success, + error, +}: { + board: any + load: () => Promise + success: (msg: string) => void + error: (msg: string) => void +}) { + const [editing, setEditing] = useState(false) + const [name, setName] = useState(board.name) + const [slug, setSlug] = useState(board.slug) + const [boardType, setBoardType] = useState(board.type) + + async function handleEdit(id: string) { + setEditing(true) + } + + async function handleDelete(id: string) { + const ok = confirm( + "정말로 게시판을 삭제하시겠습니까? (관련 게시글은 삭제됩니다)", + ) + if (!ok) return + try { + await deleteBoard(id) + await load() + success("게시판이 삭제되었습니다.") + } catch (err) { + console.error(err) + error("게시판 삭제 실패") + } + } + + async function handleSave(id: string) { + if (!name.trim() || !slug.trim()) { + error("이름과 슬러그를 입력하세요.") + return + } + try { + await axios.put(`/community/boards/${id}`, { + name, + slug, + }) + setEditing(false) + await load() + success("게시판이 수정되었습니다.") + } catch (err) { + console.error(err) + error("게시판 수정 실패") + } + } + + return ( + + + + + {editing ? ( + setName(e.target.value)} + /> + ) : ( + {name} + )} + {editing ? ( + setSlug(e.target.value)} + /> + ) : ( + + {slug} · {board.visibility} + + )} + + + {editing ? ( + + ) : ( + + {boardType === "free" ? "자유 게시판" : "Q&A 게시판"} + + )} + + + {editing ? ( + handleSave(board.id)}> + + + ) : ( + handleEdit(board.id)}> + + + )} + handleDelete(board.id)}> + + + + + ) +} diff --git a/client/src/app/admin/community/boards/page.tsx b/client/src/app/admin/community/boards/page.tsx new file mode 100644 index 00000000..3b7c03e9 --- /dev/null +++ b/client/src/app/admin/community/boards/page.tsx @@ -0,0 +1,163 @@ +"use client" + +import { useEffect, useState } from "react" +import { + Box, + Button, + Card, + CardContent, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, + Stack, + TextField, + Typography, + IconButton, +} from "@mui/material" +import useAuth from "@/hooks/useAuth" +import { useNotification } from "@/hooks/useNotification" +import { fetchBoards, createBoard } from "@/app/community/community.api" +import { CommunityBoard } from "@/app/community/community.types" +import Board from "./board" + +export default function AdminCommunityBoardsPage() { + const { isAdminIfNotExit } = useAuth() + const { success, error } = useNotification() + const [loading, setLoading] = useState(true) + const [boards, setBoards] = useState([]) + + const [name, setName] = useState("") + const [slug, setSlug] = useState("") + const [boardType, setBoardType] = useState("free") + const [creating, setCreating] = useState(false) + + useEffect(() => { + if (!isAdminIfNotExit("/admin/community/boards")) return + load() + }, []) + + async function load() { + try { + setLoading(true) + const list = await fetchBoards() + setBoards(list) + } catch (err) { + console.error(err) + error("게시판 목록을 불러오지 못했습니다.") + } finally { + setLoading(false) + } + } + + async function handleCreate() { + if (!name.trim() || !slug.trim()) { + error("이름과 슬러그를 입력하세요.") + return + } + try { + setCreating(true) + await createBoard({ + name: name.trim(), + slug: slug.trim(), + description: "", + boardType: boardType as "free" | "qna", + }) + setName("") + setSlug("") + setBoardType("free") + await load() + success("게시판이 생성되었습니다.") + } catch (err: any) { + console.error(err) + error(err?.response?.data?.error || "게시판 생성 실패") + } finally { + setCreating(false) + } + } + + if (loading) + return ( + + + + ) + + return ( + + + + + + + 게시판 관리 + + + 관리자가 보드 생성/삭제를 할 수 있습니다. + + + + + + + + + setName(e.target.value)} + /> + setSlug(e.target.value)} + /> + + 유형 + + + + + + + + + {boards.map((b) => ( + + ))} + + + + ) +} diff --git a/client/src/app/admin/community/qna/page.tsx b/client/src/app/admin/community/qna/page.tsx new file mode 100644 index 00000000..2671be2f --- /dev/null +++ b/client/src/app/admin/community/qna/page.tsx @@ -0,0 +1,366 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import dayjs from "dayjs" +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Dialog, + DialogContent, + FormControlLabel, + IconButton, + Stack, + Switch, + TextField, + Typography, + useMediaQuery, +} from "@mui/material" +import CloseIcon from "@mui/icons-material/Close" +import { useTheme } from "@mui/material/styles" +import useAuth from "@/hooks/useAuth" +import { useNotification } from "@/hooks/useNotification" +import { + answerQnaPost, + fetchBoards, + fetchPost, + fetchPosts, +} from "@/app/community/community.api" +import { CommunityBoard, CommunityPost } from "@/app/community/community.types" + +function formatDate(value?: string | null) { + if (!value) return "-" + return dayjs(value).format("YYYY.MM.DD HH:mm") +} + +function isQnaBoard(board: CommunityBoard) { + return ( + board.slug.toLowerCase().includes("qna") || + board.name.toLowerCase().includes("qna") + ) +} + +export default function AdminCommunityQnaPage() { + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down("sm")) + const { isAdminIfNotExit } = useAuth() + const { success, error } = useNotification() + const [loading, setLoading] = useState(true) + const [boards, setBoards] = useState([]) + const [selectedBoard, setSelectedBoard] = useState( + null, + ) + const [posts, setPosts] = useState([]) + const [selectedPost, setSelectedPost] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + const [saving, setSaving] = useState(false) + const [answer, setAnswer] = useState("") + const [answerPublic, setAnswerPublic] = useState(false) + + const qnaBoards = useMemo(() => boards.filter(isQnaBoard), [boards]) + + useEffect(() => { + if (!isAdminIfNotExit("/admin/community/qna")) { + return + } + loadData() + }, []) + + async function loadData() { + try { + setLoading(true) + const boardList = await fetchBoards() + setBoards(boardList) + const initialBoard = boardList.find(isQnaBoard) || boardList[0] || null + setSelectedBoard(initialBoard) + if (initialBoard) { + const postList = await fetchPosts(initialBoard.id, "qna") + setPosts(postList) + } else { + setPosts([]) + } + } catch (err) { + console.error(err) + error("QnA 데이터를 불러오지 못했습니다.") + } finally { + setLoading(false) + } + } + + async function selectBoard(board: CommunityBoard) { + setSelectedBoard(board) + const postList = await fetchPosts(board.id, "qna") + setPosts(postList) + } + + async function openAnswerPanel(post: CommunityPost) { + try { + const detail = await fetchPost(post.id) + setSelectedPost(detail) + setAnswer(detail.answer || "") + setAnswerPublic(Boolean(detail.answerPublic)) + setDetailOpen(true) + } catch (err) { + console.error(err) + error("질문 상세를 불러오지 못했습니다.") + } + } + + async function handleSaveAnswer() { + if (!selectedPost) return + if (!answer.trim()) { + error("답변을 입력해주세요.") + return + } + + try { + setSaving(true) + await answerQnaPost({ + postId: selectedPost.id, + answer: answer.trim(), + answerPublic, + }) + const nextDetail = await fetchPost(selectedPost.id) + setSelectedPost(nextDetail) + if (selectedBoard) { + const nextPosts = await fetchPosts(selectedBoard.id, "qna") + setPosts(nextPosts) + } + success("답변이 저장되었습니다.") + } catch (err) { + console.error(err) + error("답변 저장에 실패했습니다.") + } finally { + setSaving(false) + } + } + + if (loading) { + return ( + + + + ) + } + + return ( + + + + + + + + 질문 답변 관리 + + + 관리자만 접근할 수 있는 QnA 답변 화면입니다. 게시판을 선택하고 + 질문을 눌러 답변을 등록하세요. + + + + + + {qnaBoards.length === 0 ? ( + QnA 게시판이 없습니다. + ) : ( + + {qnaBoards.map((board) => ( + selectBoard(board)} + sx={{ flexShrink: 0 }} + /> + ))} + + )} + + + + 질문 목록 + + + {posts.length === 0 ? ( + + + + 질문이 아직 없습니다. + + + + ) : ( + posts.map((post) => ( + openAnswerPanel(post)} + > + + + + + {post.title || "제목 없음"} + + + + + + + + + + + )) + )} + + + + setDetailOpen(false)} + PaperProps={{ sx: { borderRadius: isMobile ? 0 : 4 } }} + > + + {!selectedPost ? null : ( + + + + + + {selectedPost.title || "제목 없음"} + + + {selectedPost.isAnonymous + ? "익명" + : selectedPost.author?.name || "작성자"}{" "} + · {formatDate(selectedPost.createdAt)} + + + setDetailOpen(false)}> + + + + + + + + + 현재 질문 상태 + + + 답변 공개 여부:{" "} + {selectedPost.answerPublic ? "공개" : "비공개"} + + + 답변 시각: {formatDate(selectedPost.answeredAt)} + + + + + + setAnswer(e.target.value)} + multiline + minRows={6} + fullWidth + /> + + setAnswerPublic(e.target.checked)} + /> + } + label="답변 공개" + /> + + + + + + + )} + + + + ) +} diff --git a/client/src/app/admin/components/Header/index.tsx b/client/src/app/admin/components/Header/index.tsx index 9219f9b4..687ac7c6 100644 --- a/client/src/app/admin/components/Header/index.tsx +++ b/client/src/app/admin/components/Header/index.tsx @@ -10,6 +10,7 @@ import HeaderDrawer, { DrawerItemsType } from "@/components/Header/Drawer" import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline" import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined" import LinkIcon from "@mui/icons-material/Link" +import ForumIcon from "@mui/icons-material/Forum" export default function AdminHeader() { const { push } = useRouter() @@ -69,10 +70,28 @@ export default function AdminHeader() { path: "/admin/link", type: "menu", }, - /* + { + title: "게시판", + icon: , + type: "submenu", + children: [ + { + title: "게시판 관리", + icon: , + path: "/admin/community/boards", + type: "menu", + }, + { + title: "QnA", + icon: , + path: "/admin/community/qna", + type: "menu", + }, + ], + }, { type: "divider", - },*/ + }, /* Todo: 지울지, 유지할지 결정 필요 { title: "워십 콘테스트", diff --git a/client/src/app/common/login/page.tsx b/client/src/app/common/login/page.tsx index ca158f0c..432affbe 100644 --- a/client/src/app/common/login/page.tsx +++ b/client/src/app/common/login/page.tsx @@ -1,8 +1,7 @@ "use client" -import { Suspense } from "react" +import { Suspense, useState } from "react" import { useEffect } from "react" -import { useSetAtom } from "jotai" import useAuth from "@/hooks/useAuth" import { Button, Stack } from "@mui/material" import { useNotification } from "@/hooks/useNotification" @@ -23,6 +22,7 @@ function Login() { const { getKakaoTokenFromAuthCode, login, isLogin } = useAuth() const { executeKakaoLogin } = useKakaoHook() const { error } = useNotification() + const [modeCounter, setModeCounter] = useState(0) useEffect(() => { const code = searchParams.get("code") @@ -40,11 +40,23 @@ function Login() { } }, [searchParams.get("code"), isLogin]) + useEffect(() => { + if (modeCounter < 5) { + return + } + const url = globalThis.location.href + if (url.includes("nuon-dev")) { + globalThis.location.href = "https://nuon.iubns.net" + } else { + globalThis.location.href = "https://nuon-dev.iubns.net" + } + }, [modeCounter]) + async function handleLogin() { try { const returnUrl = searchParams.get("returnUrl") || "/" await executeKakaoLogin(returnUrl) - } catch (error) { + } catch (e) { error("카카오 로그인 실패") } } @@ -69,6 +81,9 @@ function Login() { color: "#222", letterSpacing: -1, }} + onClick={() => { + setModeCounter(modeCounter + 1) + }} > 수원 제일 교회 청년부 diff --git a/client/src/app/community/community.api.ts b/client/src/app/community/community.api.ts new file mode 100644 index 00000000..aa4d16cd --- /dev/null +++ b/client/src/app/community/community.api.ts @@ -0,0 +1,139 @@ +import axios from "@/config/axios" +import { + CommunityBoard, + CommunityComment, + CommunityPost, +} from "./community.types" + +export async function fetchBoards(): Promise { + const { data } = await axios.get("/community/boards") + return data +} + +export async function fetchBoard(boardId: string): Promise { + const { data } = await axios.get(`/community/boards/${boardId}`) + return data +} + +export async function fetchPosts( + boardId: string, + type?: "free" | "qna", + page = 1, + limit = 20, +): Promise { + const { data } = await axios.get(`/community/boards/${boardId}/posts`, { + params: { type, page, limit }, + }) + return data +} + +export async function fetchPost(postId: string): Promise { + const { data } = await axios.get(`/community/posts/${postId}`) + return data +} + +export async function fetchComments( + postId: string, + page = 1, + limit = 20, +): Promise { + const { data } = await axios.get(`/community/posts/${postId}/comments`, { + params: { page, limit }, + }) + return data +} + +export async function createFreePost(input: { + boardId: string + title: string + content: string + isAnonymous: boolean +}) { + const { data } = await axios.post( + `/community/boards/${input.boardId}/free-posts`, + { + title: input.title, + content: input.content, + isAnonymous: input.isAnonymous, + }, + ) + return data +} + +export async function createQnaPost(input: { + boardId: string + title: string + isAnonymous: boolean +}) { + const { data } = await axios.post( + `/community/boards/${input.boardId}/qna-posts`, + { + title: input.title, + isAnonymous: input.isAnonymous, + }, + ) + return data +} + +export async function createComment(input: { + postId: string + content: string + isAnonymous: boolean +}) { + const { data } = await axios.post( + `/community/posts/${input.postId}/comments`, + { + content: input.content, + isAnonymous: input.isAnonymous, + }, + ) + return data +} + +export async function answerQnaPost(input: { + postId: string + answer: string + answerPublic: boolean +}) { + const { data } = await axios.post( + `/community/qna-posts/${input.postId}/answer`, + { + answer: input.answer, + answerPublic: input.answerPublic, + }, + ) + return data +} + +export async function updatePost( + postId: string, + input: { title?: string; content?: string; isAnonymous?: boolean }, +) { + const { data } = await axios.put(`/community/posts/${postId}`, input) + return data +} + +export async function deletePost(postId: string) { + const { data } = await axios.delete(`/community/posts/${postId}`) + return data +} + +export async function deleteComment(commentId: string) { + const { data } = await axios.delete(`/community/comments/${commentId}`) + return data +} + +export async function createBoard(input: { + name: string + slug: string + description?: string + boardType?: "free" | "qna" +}) { + const { data } = await axios.post(`/community/boards`, input) + return data +} + +export async function deleteBoard(boardId: string) { + const { data } = await axios.delete(`/community/boards/${boardId}`) + return data +} diff --git a/client/src/app/community/community.types.ts b/client/src/app/community/community.types.ts new file mode 100644 index 00000000..aa874ce4 --- /dev/null +++ b/client/src/app/community/community.types.ts @@ -0,0 +1,58 @@ +export type CommunityVisibility = "public" | "members" | "private" + +export type CommunityUser = { + id: string + name?: string | null +} + +export type CommunityBoard = { + id: string + name: string + slug: string + description?: string | null + visibility: CommunityVisibility + type: "free" | "qna" + createdAt?: string + updatedAt?: string + deletedAt?: string | null + createdBy?: CommunityUser | null + moderators?: CommunityUser[] +} + +export type CommunityReaction = { + id: string + type: string + createdAt?: string + user?: CommunityUser | null +} + +export type CommunityComment = { + id: string + content: string + isAnonymous: boolean + createdAt?: string + deletedAt?: string | null + author?: CommunityUser | null + children?: CommunityComment[] +} + +export type CommunityPostType = "free" | "qna" + +export type CommunityPost = { + id: string + type: CommunityPostType + title?: string | null + content?: string | null + isAnonymous: boolean + createdAt?: string + updatedAt?: string + deletedAt?: string | null + board: CommunityBoard + author?: CommunityUser | null + comments?: CommunityComment[] + reactions?: CommunityReaction[] + answer?: string | null + answerPublic?: boolean + answeredAt?: string | null + answeredBy?: CommunityUser | null +} diff --git a/client/src/app/community/components/CommunityBoardClient.tsx b/client/src/app/community/components/CommunityBoardClient.tsx new file mode 100644 index 00000000..6a8e6d32 --- /dev/null +++ b/client/src/app/community/components/CommunityBoardClient.tsx @@ -0,0 +1,751 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import dayjs from "dayjs" +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + Dialog, + DialogContent, + Divider, + FormControlLabel, + IconButton, + CircularProgress, + Stack, + Switch, + TextField, + Typography, + useMediaQuery, +} from "@mui/material" +import CloseIcon from "@mui/icons-material/Close" +import DeleteIcon from "@mui/icons-material/Delete" +import { useTheme } from "@mui/material/styles" +import { useRouter } from "next/navigation" +import { useNotification } from "@/hooks/useNotification" +import { + createComment, + createFreePost, + createQnaPost, + fetchBoards, + fetchComments, + fetchPost, + fetchPosts, + updatePost, + deletePost, + deleteComment, +} from "../community.api" +import { + CommunityBoard, + CommunityComment, + CommunityPost, +} from "../community.types" +import useAuth from "@/hooks/useAuth" + +type CommunityBoardClientProps = { + boardSlug: string +} + +function formatDate(value?: string | null) { + if (!value) return "-" + return dayjs(value).format("YYYY.MM.DD HH:mm") +} + +function getBoardMode(slug: string) { + return slug.toLowerCase().includes("qna") ? "qna" : "free" +} + +function getCardBackground(mode: "free" | "qna") { + return mode === "qna" + ? "linear-gradient(180deg, rgba(124,77,255,0.12), rgba(124,77,255,0.04))" + : "linear-gradient(180deg, rgba(25,118,210,0.12), rgba(25,118,210,0.04))" +} + +export default function CommunityBoardClient({ + boardSlug, +}: CommunityBoardClientProps) { + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down("sm")) + const { success, error } = useNotification() + const router = useRouter() + const [boards, setBoards] = useState([]) + const [board, setBoard] = useState(null) + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [posting, setPosting] = useState(false) + const [detailOpen, setDetailOpen] = useState(false) + const [selectedPost, setSelectedPost] = useState(null) + const [comments, setComments] = useState([]) + const [detailLoading, setDetailLoading] = useState(false) + const [commentText, setCommentText] = useState("") + const [commentAnonymous, setCommentAnonymous] = useState(false) + const [postTitle, setPostTitle] = useState("") + const [postContent, setPostContent] = useState("") + const [postAnonymous, setPostAnonymous] = useState(false) + const { authUserData } = useAuth() + const [isEditing, setIsEditing] = useState(false) + const [editTitle, setEditTitle] = useState("") + const [editContent, setEditContent] = useState("") + + const boardMode = useMemo( + () => (board ? getBoardMode(board.slug) : "free"), + [board], + ) + + useEffect(() => { + loadBoardAndPosts() + }, [boardSlug]) + + async function loadBoardAndPosts() { + try { + setLoading(true) + const boardList = await fetchBoards() + setBoards(boardList) + const current = boardList.find((item) => item.slug === boardSlug) || null + setBoard(current) + if (current) { + const postList = await fetchPosts( + current.id, + getBoardMode(current.slug), + ) + setPosts(postList) + } else { + setPosts([]) + } + } catch (err) { + console.error(err) + error("게시판을 불러오지 못했습니다.") + } finally { + setLoading(false) + } + } + + async function refreshPosts(targetBoard?: CommunityBoard | null) { + const currentBoard = targetBoard ?? board + if (!currentBoard) return + const postList = await fetchPosts( + currentBoard.id, + getBoardMode(currentBoard.slug), + ) + setPosts(postList) + } + + async function openPost(post: CommunityPost) { + try { + setDetailLoading(true) + const [postDetail, commentList] = await Promise.all([ + fetchPost(post.id), + fetchComments(post.id), + ]) + setSelectedPost(postDetail) + setComments(commentList) + setDetailOpen(true) + setCommentText("") + setCommentAnonymous(false) + setIsEditing(false) + } catch (err) { + console.error(err) + error("게시글을 불러오지 못했습니다.") + } finally { + setDetailLoading(false) + } + } + + function startEdit() { + if (!selectedPost) return + setEditTitle(selectedPost.title || "") + setEditContent(selectedPost.content || "") + setIsEditing(true) + } + + async function saveEdit() { + if (!selectedPost) return + if (!editTitle.trim()) { + error("제목을 입력해주세요.") + return + } + try { + setDetailLoading(true) + await updatePost(selectedPost.id, { + title: editTitle.trim(), + content: editContent?.trim(), + }) + const updated = await fetchPost(selectedPost.id) + setSelectedPost(updated) + await refreshPosts() + setIsEditing(false) + success("게시글이 수정되었습니다.") + } catch (err) { + console.error(err) + error("게시글 수정에 실패했습니다.") + } finally { + setDetailLoading(false) + } + } + + async function handleDeletePost() { + if (!selectedPost) return + const ok = confirm("정말로 게시글을 삭제하시겠습니까?") + if (!ok) return + try { + await deletePost(selectedPost.id) + setDetailOpen(false) + await refreshPosts() + success("게시글이 삭제되었습니다.") + } catch (err) { + console.error(err) + error("게시글 삭제에 실패했습니다.") + } + } + + async function handleDeleteComment(commentId: string) { + const ok = confirm("정말로 댓글을 삭제하시겠습니까?") + if (!ok) return + try { + await deleteComment(commentId) + if (selectedPost) { + const updated = await fetchComments(selectedPost.id) + setComments(updated) + } + success("댓글이 삭제되었습니다.") + } catch (err) { + console.error(err) + error("댓글 삭제에 실패했습니다.") + } + } + + async function handleCreatePost() { + if (!board) return + if (!postTitle.trim()) { + error("제목을 입력해주세요.") + return + } + + try { + setPosting(true) + if (boardMode === "qna") { + await createQnaPost({ + boardId: board.id, + title: postTitle.trim(), + isAnonymous: postAnonymous, + }) + } else { + if (!postContent.trim()) { + error("내용을 입력해주세요.") + return + } + await createFreePost({ + boardId: board.id, + title: postTitle.trim(), + content: postContent.trim(), + isAnonymous: postAnonymous, + }) + } + + setPostTitle("") + setPostContent("") + setPostAnonymous(false) + await refreshPosts() + success("게시글이 등록되었습니다.") + } catch (err) { + console.error(err) + error("게시글 등록에 실패했습니다.") + } finally { + setPosting(false) + } + } + + async function handleAddComment() { + if (!selectedPost) return + if (!commentText.trim()) { + error("댓글 내용을 입력해주세요.") + return + } + + try { + await createComment({ + postId: selectedPost.id, + content: commentText.trim(), + isAnonymous: commentAnonymous, + }) + setCommentText("") + setCommentAnonymous(false) + const updatedComments = await fetchComments(selectedPost.id) + setComments(updatedComments) + success("댓글이 등록되었습니다.") + } catch (err) { + console.error(err) + error("댓글 등록에 실패했습니다.") + } + } + + const selectedBoardAccent = boardMode === "qna" ? "#7c4dff" : "#1976d2" + + if (loading) { + return ( + + + + ) + } + + if (!board) { + return ( + + + 해당 게시판을 찾을 수 없습니다. 현재 접근 가능한 게시판:{" "} + {boards.length}개 + + + ) + } + + return ( + + + + + + + + + + + + {board.name} + + + {board.description || "게시판 설명이 없습니다."} + + + + + + + + + + + {boardMode === "qna" ? "질문 작성" : "새 글 작성"} + + + + + + + + setPostTitle(e.target.value)} + fullWidth + /> + + {boardMode === "free" && ( + setPostContent(e.target.value)} + fullWidth + multiline + minRows={4} + /> + )} + + + setPostAnonymous(e.target.checked)} + /> + } + label="익명" + /> + + + + + + + + + 게시글 {posts.length}개 + + + {posts.length === 0 ? ( + + + + 아직 등록된 게시글이 없습니다. + + + + ) : ( + posts.map((post) => ( + openPost(post)} + > + + + + + {post.title || "제목 없음"} + + {post.type === "qna" && ( + + )} + + {post.content && boardMode === "free" && ( + + {post.content} + + )} + + + + + + + + )) + )} + + + + setDetailOpen(false)} + PaperProps={{ sx: { borderRadius: isMobile ? 0 : 4 } }} + > + + {detailLoading || !selectedPost ? ( + + + + ) : ( + + + + + {isEditing ? ( + setEditTitle(e.target.value)} + fullWidth + /> + ) : ( + + {selectedPost.title || "제목 없음"} + + )} + + {selectedPost.isAnonymous + ? "익명" + : selectedPost.author?.name || "작성자"}{" "} + · {formatDate(selectedPost.createdAt)} + + + + {selectedPost && + (selectedPost.author?.id === authUserData?.id || + authUserData?.role?.Admin) && ( + <> + {!isEditing ? ( + + ) : ( + + )} + + + + + )} + setDetailOpen(false)}> + + + + + + + + {isEditing ? ( + selectedPost.type === "free" ? ( + setEditContent(e.target.value)} + fullWidth + multiline + minRows={6} + /> + ) : null + ) : ( + selectedPost.content && ( + + {selectedPost.content} + + ) + )} + + {selectedPost.type === "qna" && ( + + + + + 답변 + + {selectedPost.answer ? ( + <> + + {selectedPost.answer} + + + {selectedPost.answeredAt + ? `답변 시각: ${formatDate(selectedPost.answeredAt)}` + : ""} + + + ) : ( + + 아직 답변이 없습니다. + + )} + + + + )} + + + + + + 댓글 {comments.length}개 + + + {comments.length === 0 ? ( + + 댓글이 없습니다. + + ) : ( + comments.map((comment) => ( + + + + + + {comment.isAnonymous + ? "익명" + : comment.author?.name || "작성자"} + + + {formatDate(comment.createdAt)} + + + + {(comment.author?.id === authUserData?.id || + authUserData?.role?.Admin) && ( + + handleDeleteComment(comment.id) + } + > + + + )} + + + + {comment.content} + + + + )) + )} + + + + + + + + + + 댓글 작성 + + setCommentText(e.target.value)} + multiline + minRows={3} + fullWidth + /> + + + setCommentAnonymous(e.target.checked) + } + /> + } + label="익명" + /> + + + + + + + )} + + + + ) +} diff --git a/client/src/app/community/components/CommunityWriteClient.tsx b/client/src/app/community/components/CommunityWriteClient.tsx new file mode 100644 index 00000000..cf4c8918 --- /dev/null +++ b/client/src/app/community/components/CommunityWriteClient.tsx @@ -0,0 +1,119 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { + Box, + Button, + Card, + CardContent, + Chip, + FormControlLabel, + Stack, + Switch, + TextField, + Typography, + CircularProgress, +} from "@mui/material" +import { fetchBoards, createFreePost, createQnaPost } from "../community.api" +import { CommunityBoard } from "../community.types" +import { useNotification } from "@/hooks/useNotification" + +type Props = { boardSlug: string } + +export default function CommunityWriteClient({ boardSlug }: Props) { + const router = useRouter() + const { success, error } = useNotification() + const [boards, setBoards] = useState([]) + const [loading, setLoading] = useState(true) + const [board, setBoard] = useState(null) + + const [title, setTitle] = useState("") + const [content, setContent] = useState("") + const [isAnonymous, setIsAnonymous] = useState(false) + const [posting, setPosting] = useState(false) + + useEffect(() => { + load() + }, [boardSlug]) + + async function load() { + try { + setLoading(true) + const list = await fetchBoards() + setBoards(list) + const cur = list.find((b) => b.slug === boardSlug) || null + setBoard(cur) + } catch (err) { + console.error(err) + error("게시판 정보를 불러오지 못했습니다.") + } finally { + setLoading(false) + } + } + + async function handleSubmit() { + if (!board) return + if (!title.trim()) { + error("제목을 입력해주세요.") + return + } + try { + setPosting(true) + if (board.slug.toLowerCase().includes("qna")) { + await createQnaPost({ boardId: board.id, title: title.trim(), isAnonymous }) + } else { + if (!content.trim()) { + error("내용을 입력해주세요.") + return + } + await createFreePost({ boardId: board.id, title: title.trim(), content: content.trim(), isAnonymous }) + } + success("게시글이 등록되었습니다.") + router.push(`/community/${encodeURIComponent(board.slug)}`) + } catch (err) { + console.error(err) + error("게시글 등록에 실패했습니다.") + } finally { + setPosting(false) + } + } + + if (loading) return + + if (!board) return 게시판을 찾을 수 없습니다. + + const isQna = board.slug.toLowerCase().includes("qna") + + return ( + + + + + + + {board.name} — 글쓰기 + 작성 후 자동으로 게시판으로 이동합니다. + + + + + + + + setTitle(e.target.value)} fullWidth /> + {!isQna && ( + setContent(e.target.value)} fullWidth multiline minRows={6} /> + )} + setIsAnonymous(e.target.checked)} />} label="익명" /> + + + + + + + + + + ) +} diff --git a/client/src/app/community/layout.tsx b/client/src/app/community/layout.tsx new file mode 100644 index 00000000..9054fa67 --- /dev/null +++ b/client/src/app/community/layout.tsx @@ -0,0 +1,17 @@ +"use client" + +import Header from "@/components/Header" +import { Stack } from "@mui/material" + +export default function CommonLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + +
+ {children} + + ) +} diff --git a/client/src/app/community/page.tsx b/client/src/app/community/page.tsx new file mode 100644 index 00000000..36e72bd1 --- /dev/null +++ b/client/src/app/community/page.tsx @@ -0,0 +1,52 @@ +"use client" + +import { Stack } from "@mui/material" +import { useSearchParams } from "next/navigation" +import useCommunity from "./useCommunity" +import CommunityBoardClient from "./components/CommunityBoardClient" +import { Suspense } from "react" + +export default function CommunityHomePage() { + return ( + 게시판 정보를 불러오는 중...}> + + + ) +} + +function CommunityHomePageContent() { + const searchParams = useSearchParams() + + const slug = searchParams.get("slug") + + if (!slug) { + return + } + + const { board } = useCommunity(slug) + if (!board) { + return <>게시판 정보를 불러오는 중... + } + + return ( +
+ {slug &&
현재 선택된 커뮤니티: {slug}
} + +
+ ) +} + +function ErrorSlug() { + return ( + + 존재하지 않는 게시판입니다. + + ) +} diff --git a/client/src/app/community/useCommunity.ts b/client/src/app/community/useCommunity.ts new file mode 100644 index 00000000..4362f6f0 --- /dev/null +++ b/client/src/app/community/useCommunity.ts @@ -0,0 +1,31 @@ +"use client" + +import axios from "@/config/axios" +import { atom, useAtom } from "jotai" +import { useEffect } from "react" + +const boardAtom = atom({ + key: "community/board", + default: null as null | any, +}) + +export default function useCommunity(slug: string) { + const [board, setBoard] = useAtom(boardAtom) + + useEffect(() => { + load() + }, [slug]) + + async function load() { + try { + const { data } = await axios.get(`/community/boards/${slug}`) + setBoard(data) + } catch (err) { + console.error(err) + } + } + + return { + board, + } +} diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx index 7eba8f3e..9c686a0a 100644 --- a/client/src/app/page.tsx +++ b/client/src/app/page.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import useAuth from "@/hooks/useAuth" import Link from "@/app/link/page" +import { AxiosError } from "axios" export default function Index() { const { isLogin } = useAuth() @@ -13,6 +14,7 @@ export default function Index() { useEffect(() => { checkLogin() + isApp() }, []) async function checkLogin() { @@ -21,6 +23,24 @@ export default function Index() { } } + function isApp() { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (typeof window !== "undefined" && (window as any).ReactNativeWebView) { + alert("앱에서 접속하셨습니다.") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).ReactNativeWebView.postMessage( + JSON.stringify({ + platform: "web", + }), + ) + } + } catch (error: unknown) { + const axiosError = error as AxiosError + alert(`Error: ${axiosError.message}`) + } + } + return (
diff --git a/client/src/components/Header/Drawer.tsx b/client/src/components/Header/Drawer.tsx index a7c015bf..e789a745 100644 --- a/client/src/components/Header/Drawer.tsx +++ b/client/src/components/Header/Drawer.tsx @@ -2,6 +2,7 @@ import { Box, + Collapse, Divider, Drawer, List, @@ -14,6 +15,8 @@ import { useRouter } from "next/navigation" import UserInformation from "./UserInformation" import useAuth from "@/hooks/useAuth" import LogoutIcon from "@mui/icons-material/Logout" +import { ExpandLess, ExpandMore } from "@mui/icons-material" +import { useState } from "react" interface HeaderDrawerProps { isOpen: boolean @@ -25,7 +28,71 @@ export interface DrawerItemsType { title?: string icon?: React.ReactNode path?: string - type: "divider" | "menu" + type: "divider" | "menu" | "submenu" + children?: Array +} + +function DrawerList({ DrawerItems }: { DrawerItems: Array }) { + const { push } = useRouter() + + const [open, setOpen] = useState(false) + + function goToPage(path?: string) { + push(path || "/") + } + + function toggleSubMenu(e: React.MouseEvent) { + setOpen(!open) + e.stopPropagation() + } + + return ( +
+ {DrawerItems.map((item, index) => { + if (item.type === "divider") { + return + } else if (item.type === "menu") { + return ( + + goToPage(item.path)} + sx={{ + borderRadius: 2, + mx: 1, + "&:hover": { + bgcolor: "#f5f5f5", + transform: "translateX(4px)", + }, + transition: "all 0.2s ease", + }} + > + {item.icon} + + + + ) + } else if (item.type === "submenu" && item.children) { + return ( +
+ + + {item.icon} + + {open ? : } + + + + + + + +
+ ) + } + return null + })} +
+ ) } export default function HeaderDrawer({ @@ -33,13 +100,8 @@ export default function HeaderDrawer({ toggleDrawer, DrawerItems, }: HeaderDrawerProps) { - const { push } = useRouter() const { logout, isLogin } = useAuth() - function goToPage(path?: string) { - push(path || "/") - } - return ( - {DrawerItems.map((item, index) => { - if (item.type === "divider") { - return - } else if (item.type === "menu") { - return ( - - goToPage(item.path)} - sx={{ - borderRadius: 2, - mx: 1, - "&:hover": { - bgcolor: "#f5f5f5", - transform: "translateX(4px)", - }, - transition: "all 0.2s ease", - }} - > - - {item.icon} - - - - - ) - } - })} + {isLogin && ( = [ { title: "나의 정보 수정", @@ -29,40 +33,36 @@ export default function Header() { path: "/common/myPage", type: "menu", }, - /*Todo: 수련회 신청 기간에 맞춰서 다시 열기 - { type: "divider" }, { - title: "2026 겨울 수련회 신청", - icon: , - path: "/retreat", + type: "divider", + }, + ...(boards.map((b) => ({ + title: b.name, + icon: , + path: `/community/?slug=${encodeURIComponent(b.slug)}`, type: "menu", + })) as Array), + { + type: "divider", }, - */ ] if (authUserData?.role.Leader) { DrawerItems.push({ type: "divider", }) - ;(DrawerItems.push({ + DrawerItems.push({ title: "순원 관리", icon: , path: "/leader/management", type: "menu", - }), - DrawerItems.push({ - title: "출석 관리", - icon: , - path: "/leader/attendance", - type: "menu", - })) - /*Todo: 다음 수련회때 다시 키기 - { - title: "순원 수련회 접수 조회", - icon: , - path: "/leader/retreat-attendance", + }) + DrawerItems.push({ + title: "출석 관리", + icon: , + path: "/leader/attendance", type: "menu", - },*/ + }) if (authUserData?.role.VillageLeader) { DrawerItems.push({ diff --git a/client/src/components/Header/useBoard.ts b/client/src/components/Header/useBoard.ts new file mode 100644 index 00000000..2ceaf84d --- /dev/null +++ b/client/src/components/Header/useBoard.ts @@ -0,0 +1,23 @@ +import { atom, useAtom } from "jotai" +import axios from "@/config/axios" +import { useEffect } from "react" +import { CommunityBoard } from "@/app/community/community.types" + +const boardAtom = atom([]) + +export default function useBoard() { + const [boards, setBoards] = useAtom(boardAtom) + + useEffect(() => { + if (boards.length === 0) { + fetchBoards() + } + }, []) + + async function fetchBoards() { + const response = await axios.get("/community/boards") + setBoards(response.data) + } + + return { boards } +} diff --git a/client/tsconfig.json b/client/tsconfig.json index 28e6dfe0..3cc78fa9 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -10,7 +10,7 @@ "incremental": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "Node", + "moduleResolution": "node", "jsx": "preserve", "isolatedModules": true, "noImplicitAny": true, diff --git a/server/package-lock.json b/server/package-lock.json index db19c244..a8a63da3 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -10,9 +10,12 @@ "license": "ISC", "dependencies": { "body-parser": "2.2.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "16.5.0", "express": "5.1.0", + "jsonwebtoken": "^9.0.3", + "lodash": "^4.17.21", "multer": "2.0.1", "mysql2": "3.14.1", "nodemon": "3.1.10", @@ -21,17 +24,21 @@ }, "devDependencies": { "@types/body-parser": "1.19.6", + "@types/cookie-parser": "^1.4.10", + "@types/cors": "^2.8.19", "@types/express": "5.0.3", + "@types/lodash": "^4.17.19", "@types/multer": "1.4.13", - "@types/node": "22.15.30" + "@types/node": "^22.15.30", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0" } }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "optional": true, - "peer": true, + "devOptional": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -132,8 +139,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "optional": true, - "peer": true, + "devOptional": true, "engines": { "node": ">=6.0.0" } @@ -142,15 +148,13 @@ "version": "1.4.14", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "optional": true, - "peer": true + "devOptional": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "optional": true, - "peer": true, + "devOptional": true, "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -174,29 +178,25 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "optional": true, - "peer": true + "devOptional": true }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "optional": true, - "peer": true + "devOptional": true }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "optional": true, - "peer": true + "devOptional": true }, "node_modules/@tsconfig/node16": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", - "optional": true, - "peer": true + "devOptional": true }, "node_modules/@types/body-parser": { "version": "1.19.6", @@ -217,6 +217,26 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/express": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", @@ -240,6 +260,13 @@ "@types/send": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -260,6 +287,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.30.tgz", "integrity": "sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==", "devOptional": true, + "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } @@ -323,8 +351,7 @@ "version": "8.8.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", - "optional": true, - "peer": true, + "devOptional": true, "bin": { "acorn": "bin/acorn" }, @@ -336,8 +363,7 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "optional": true, - "peer": true, + "devOptional": true, "engines": { "node": ">=0.4.0" } @@ -401,8 +427,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "optional": true, - "peer": true + "devOptional": true }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", @@ -506,6 +531,12 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -638,13 +669,33 @@ } }, "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -669,8 +720,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "optional": true, - "peer": true + "devOptional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -739,8 +789,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "optional": true, - "peer": true, + "devOptional": true, "engines": { "node": ">=0.3.1" } @@ -774,6 +823,15 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1234,6 +1292,110 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/long": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/long/-/long-5.2.4.tgz", @@ -1265,8 +1427,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "optional": true, - "peer": true + "devOptional": true }, "node_modules/math-intrinsics": { "version": "1.1.0", @@ -2020,6 +2181,16 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2051,11 +2222,11 @@ } }, "node_modules/ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", - "optional": true, - "peer": true, + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -2094,6 +2265,21 @@ } } }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2226,7 +2412,7 @@ "version": "4.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.3.tgz", "integrity": "sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig==", - "optional": true, + "devOptional": true, "peer": true, "bin": { "tsc": "bin/tsc", @@ -2276,8 +2462,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "optional": true, - "peer": true + "devOptional": true }, "node_modules/vary": { "version": "1.1.2", @@ -2397,8 +2582,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "optional": true, - "peer": true, + "devOptional": true, "engines": { "node": ">=6" } @@ -2409,8 +2593,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "optional": true, - "peer": true, + "devOptional": true, "requires": { "@jridgewell/trace-mapping": "0.3.9" } @@ -2477,22 +2660,19 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "optional": true, - "peer": true + "devOptional": true }, "@jridgewell/sourcemap-codec": { "version": "1.4.14", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "optional": true, - "peer": true + "devOptional": true }, "@jridgewell/trace-mapping": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "optional": true, - "peer": true, + "devOptional": true, "requires": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -2513,29 +2693,25 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "optional": true, - "peer": true + "devOptional": true }, "@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "optional": true, - "peer": true + "devOptional": true }, "@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "optional": true, - "peer": true + "devOptional": true }, "@tsconfig/node16": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", - "optional": true, - "peer": true + "devOptional": true }, "@types/body-parser": { "version": "1.19.6", @@ -2556,6 +2732,22 @@ "@types/node": "*" } }, + "@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "requires": {} + }, + "@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/express": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", @@ -2579,6 +2771,12 @@ "@types/send": "*" } }, + "@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true + }, "@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -2661,15 +2859,13 @@ "version": "8.8.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", - "optional": true, - "peer": true + "devOptional": true }, "acorn-walk": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "optional": true, - "peer": true + "devOptional": true }, "ansi-regex": { "version": "5.0.1", @@ -2712,8 +2908,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "optional": true, - "peer": true + "devOptional": true }, "aws-ssl-profiles": { "version": "1.1.2", @@ -2777,6 +2972,11 @@ "ieee754": "^1.2.1" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2871,9 +3071,25 @@ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" }, "cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==" + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + }, + "cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "requires": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + } + } }, "cookie-signature": { "version": "1.2.2", @@ -2893,8 +3109,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "optional": true, - "peer": true + "devOptional": true }, "cross-spawn": { "version": "7.0.6", @@ -2939,8 +3154,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "optional": true, - "peer": true + "devOptional": true }, "dotenv": { "version": "16.5.0", @@ -2962,6 +3176,14 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3286,6 +3508,88 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "requires": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + } + }, + "jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "requires": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "requires": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" + }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "long": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/long/-/long-5.2.4.tgz", @@ -3305,8 +3609,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "optional": true, - "peer": true + "devOptional": true }, "math-intrinsics": { "version": "1.1.0", @@ -3828,6 +4131,12 @@ "ansi-regex": "^5.0.1" } }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3850,11 +4159,10 @@ } }, "ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", - "optional": true, - "peer": true, + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, "requires": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -3871,6 +4179,17 @@ "yn": "3.1.1" } }, + "tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "requires": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, "tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3916,7 +4235,7 @@ "version": "4.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.3.tgz", "integrity": "sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig==", - "optional": true, + "devOptional": true, "peer": true }, "undefsafe": { @@ -3949,8 +4268,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "optional": true, - "peer": true + "devOptional": true }, "vary": { "version": "1.1.2", @@ -4035,8 +4353,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "optional": true, - "peer": true + "devOptional": true } } } diff --git a/server/package.json b/server/package.json index dbc1eb41..19ada962 100644 --- a/server/package.json +++ b/server/package.json @@ -35,6 +35,8 @@ }, "devDependencies": { "@types/body-parser": "1.19.6", + "@types/cookie-parser": "^1.4.10", + "@types/cors": "^2.8.19", "@types/express": "5.0.3", "@types/lodash": "^4.17.19", "@types/multer": "1.4.13", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index 52a507d0..14f375fa 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -48,6 +48,12 @@ importers: '@types/body-parser': specifier: 1.19.6 version: 1.19.6 + '@types/cookie-parser': + specifier: ^1.4.10 + version: 1.4.10(@types/express@5.0.3) + '@types/cors': + specifier: ^2.8.19 + version: 2.8.19 '@types/express': specifier: 5.0.3 version: 5.0.3 @@ -112,6 +118,14 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie-parser@1.4.10': + resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} + peerDependencies: + '@types/express': '*' + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/express-serve-static-core@5.0.6': resolution: {integrity: sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==} @@ -1078,6 +1092,14 @@ snapshots: dependencies: '@types/node': 22.15.30 + '@types/cookie-parser@1.4.10(@types/express@5.0.3)': + dependencies: + '@types/express': 5.0.3 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.15.30 + '@types/express-serve-static-core@5.0.6': dependencies: '@types/node': 22.15.30 diff --git a/server/src/entity/attendData.ts b/server/src/entity/attendData.ts index 500eb378..fa12a93c 100644 --- a/server/src/entity/attendData.ts +++ b/server/src/entity/attendData.ts @@ -6,17 +6,17 @@ import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm" @Entity() export class AttendData { @PrimaryGeneratedColumn("uuid") - id: string + id!: string @ManyToOne(() => WorshipSchedule, (worshipSchedule) => worshipSchedule.id) - worshipSchedule: WorshipSchedule + worshipSchedule!: WorshipSchedule @ManyToOne(() => User, (user) => user.id) - user: User + user!: User @Column() - isAttend: AttendStatus + isAttend!: AttendStatus @Column({ default: "" }) - memo: string + memo!: string } diff --git a/server/src/entity/community.ts b/server/src/entity/community.ts index 6d52a46c..f9e2be7d 100644 --- a/server/src/entity/community.ts +++ b/server/src/entity/community.ts @@ -13,46 +13,46 @@ import { User } from "./user" @Entity() export class Community { @PrimaryGeneratedColumn() - id: number + id!: number @ManyToOne(() => Community, (community) => community.children, { nullable: true, }) - parent: Community | null + parent!: Community | null @OneToMany(() => Community, (community) => community.parent) - children: Community[] + children!: Community[] @Column() - name: string + name!: string @OneToMany(() => User, (user) => user.community) - users: User[] + users!: User[] @CreateDateColumn({ type: "timestamp", default: () => "CURRENT_TIMESTAMP(6)", }) - createdAt: string + createdAt!: string @UpdateDateColumn({ type: "timestamp", default: () => "CURRENT_TIMESTAMP(6)", onUpdate: "CURRENT_TIMESTAMP(6)", }) - lastModifiedAt: string + lastModifiedAt!: string @ManyToOne(() => User, { nullable: true }) @JoinColumn({ name: "leaderId" }) - leader: User | null + leader!: User | null @ManyToOne(() => User, { nullable: true }) @JoinColumn({ name: "deputyLeaderId" }) - deputyLeader: User | null + deputyLeader!: User | null @Column({ default: 0 }) - x: number + x!: number @Column({ default: 0 }) - y: number + y!: number } diff --git a/server/src/entity/community/board.ts b/server/src/entity/community/board.ts index 98f5c296..e7ca5813 100644 --- a/server/src/entity/community/board.ts +++ b/server/src/entity/community/board.ts @@ -4,11 +4,12 @@ import { Column, CreateDateColumn, UpdateDateColumn, - ManyToMany, - JoinTable, + DeleteDateColumn, ManyToOne, + OneToMany, } from "typeorm" import { User } from "../user" +import { Post } from "./post" export enum BoardVisibility { PUBLIC = "public", @@ -16,38 +17,47 @@ export enum BoardVisibility { PRIVATE = "private", } +export enum BoardType { + FREE = "free", + QNA = "qna", +} + @Entity() export class Board { @PrimaryGeneratedColumn("uuid") id!: string - @Column() + @Column({ comment: "게시판 이름" }) name!: string - @Column({ unique: true }) + @Column({ unique: true, comment: "게시판 고유 식별용 slug" }) slug!: string - @Column({ nullable: true, type: "text" }) + @Column({ nullable: true, type: "text", comment: "게시판 설명" }) description?: string @Column({ type: "enum", enum: BoardVisibility, default: BoardVisibility.PUBLIC, + comment: "게시판 공개 범위", }) 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 + @Column({ + type: "enum", + enum: BoardType, + default: BoardType.FREE, + comment: "게시판 유형", + }) + type!: BoardType + + @OneToMany(() => Post, (post) => post.board) + posts?: Post[] + @CreateDateColumn({ type: "timestamp", default: () => "CURRENT_TIMESTAMP(6)", @@ -59,4 +69,11 @@ export class Board { default: () => "CURRENT_TIMESTAMP(6)", }) updatedAt!: Date + + @DeleteDateColumn({ + type: "timestamp", + nullable: true, + comment: "소프트 삭제 시각", + }) + deletedAt?: Date | null } diff --git a/server/src/entity/community/comment.ts b/server/src/entity/community/comment.ts index 44457b3a..ce1fac74 100644 --- a/server/src/entity/community/comment.ts +++ b/server/src/entity/community/comment.ts @@ -26,21 +26,22 @@ export class Comment { @OneToMany(() => Comment, (comment) => comment.parent) children?: Comment[] - @ManyToOne(() => User, { nullable: true }) - author?: User | null + @ManyToOne(() => User, { nullable: false }) + author!: User - @Column({ type: "text" }) + @Column({ type: "text", comment: "댓글 내용" }) content!: string - @Column({ default: false }) - isAnonymous!: boolean - @CreateDateColumn({ type: "timestamp", default: () => "CURRENT_TIMESTAMP(6)", }) createdAt!: Date - @DeleteDateColumn({ type: "timestamp", nullable: true }) + @DeleteDateColumn({ + type: "timestamp", + nullable: true, + comment: "소프트 삭제 시각", + }) deletedAt?: Date | null } diff --git a/server/src/entity/community/freePost.ts b/server/src/entity/community/freePost.ts deleted file mode 100644 index 8d80fa3f..00000000 --- a/server/src/entity/community/freePost.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 index ab0c39e0..b8a70a7b 100644 --- a/server/src/entity/community/post.ts +++ b/server/src/entity/community/post.ts @@ -7,12 +7,13 @@ import { DeleteDateColumn, ManyToOne, OneToMany, - TableInheritance, - ChildEntity, + OneToOne, } from "typeorm" import { User } from "../user" +import { Board } from "./board" import { Comment } from "./comment" import { Reaction } from "./reaction" +import { QnaPost } from "./qnaPost" export enum PostType { FREE = "free", @@ -20,28 +21,25 @@ export enum PostType { } @Entity() -@TableInheritance({ - column: { type: "varchar", name: "type", default: PostType.FREE }, -}) export class Post { @PrimaryGeneratedColumn("uuid") id!: string - @Column({ type: "varchar", length: 50 }) + @Column({ type: "varchar", length: 50, comment: "게시글 타입" }) type!: PostType - @ManyToOne(() => User, { nullable: true }) - author?: User | null + @ManyToOne(() => User, { nullable: false }) + author?: User - @Column({ nullable: true }) + @ManyToOne(() => Board, (board) => board.posts, { nullable: false }) + board!: Board + + @Column({ nullable: true, comment: "게시글 제목" }) title?: string - @Column({ type: "text", nullable: true }) + @Column({ type: "text", nullable: true, comment: "게시글 본문" }) content?: string - @Column({ default: false }) - isAnonymous!: boolean - @OneToMany(() => Comment, (comment) => comment.post, { cascade: ["insert", "update"], }) @@ -52,6 +50,9 @@ export class Post { }) reactions?: Reaction[] + @OneToOne(() => QnaPost, (qna: QnaPost) => qna.post, { nullable: true }) + qna?: QnaPost + @CreateDateColumn({ type: "timestamp", default: () => "CURRENT_TIMESTAMP(6)", @@ -64,6 +65,10 @@ export class Post { }) updatedAt!: Date - @DeleteDateColumn({ type: "timestamp", nullable: true }) + @DeleteDateColumn({ + type: "timestamp", + nullable: true, + comment: "소프트 삭제 시각", + }) deletedAt?: Date | null } diff --git a/server/src/entity/community/qnaPost.ts b/server/src/entity/community/qnaPost.ts index 37f1a738..d1949e5d 100644 --- a/server/src/entity/community/qnaPost.ts +++ b/server/src/entity/community/qnaPost.ts @@ -1,17 +1,34 @@ -import { ChildEntity, Column, ManyToOne } from "typeorm" +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryGeneratedColumn, +} from "typeorm" import { User } from "../user" -import { Post, PostType } from "./post" +import { Post } from "./post" + +@Entity() +export class QnaPost { + @PrimaryGeneratedColumn("uuid") + id!: string + + @OneToOne(() => Post, (post: Post) => post.qna, { nullable: false }) + @JoinColumn({ name: "postId" }) + post!: Post -@ChildEntity(PostType.QNA) -export class QnaPost extends Post { // Admin's answer - @Column({ type: "text", nullable: true }) + @Column({ type: "text", nullable: true, comment: "관리자 답변" }) answer?: string | null @ManyToOne(() => User, { nullable: true }) answeredBy?: User | null + @Column({ type: "timestamp", nullable: true, comment: "답변 완료 시각" }) + answeredAt?: Date | null + // If true, answer is visible to everyone; otherwise only to the asker and admins - @Column({ default: false }) + @Column({ default: false, comment: "답변 공개 여부" }) answerPublic!: boolean } diff --git a/server/src/entity/community/reaction.ts b/server/src/entity/community/reaction.ts index ccb79836..0e78eaa2 100644 --- a/server/src/entity/community/reaction.ts +++ b/server/src/entity/community/reaction.ts @@ -21,7 +21,7 @@ export class Reaction { @ManyToOne(() => User, { nullable: false }) user!: User - @Column() + @Column({ comment: "반응 타입" }) type!: string @CreateDateColumn({ diff --git a/server/src/entity/event/worshipContest.ts b/server/src/entity/event/worshipContest.ts index a0b14486..54649454 100644 --- a/server/src/entity/event/worshipContest.ts +++ b/server/src/entity/event/worshipContest.ts @@ -11,24 +11,24 @@ import { User } from "../user" @Entity() export class WorshipContest { @PrimaryGeneratedColumn() - id: number + id!: number @JoinColumn({ name: "voteUserId" }) @ManyToOne(() => User, (user) => user.id) - voteUser: User + voteUser!: User @Column() - firstCommunity: string + firstCommunity!: string @Column() - secondCommunity: string + secondCommunity!: string @Column() - thirdCommunity: string + thirdCommunity!: string @Column() - term: number + term!: number @CreateDateColumn() - createdAt: Date + createdAt!: Date } diff --git a/server/src/entity/permission.ts b/server/src/entity/permission.ts index 55946889..e504ec93 100644 --- a/server/src/entity/permission.ts +++ b/server/src/entity/permission.ts @@ -5,14 +5,14 @@ import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm" @Entity() export class Permission { @PrimaryGeneratedColumn() - id: number + id!: number @ManyToOne(() => User, (user) => user.permissions) - user: User + user!: User @Column() - permissionType: PermissionType + permissionType!: PermissionType @Column() - have: boolean + have!: boolean } diff --git a/server/src/entity/retreat/inOutInfo.ts b/server/src/entity/retreat/inOutInfo.ts index 232bf645..4f2aaf31 100644 --- a/server/src/entity/retreat/inOutInfo.ts +++ b/server/src/entity/retreat/inOutInfo.ts @@ -11,32 +11,32 @@ import { RetreatAttend } from "./retreatAttend" @Entity() export class InOutInfo { @PrimaryGeneratedColumn() - id: number + id!: number @ManyToOne(() => RetreatAttend, (retreatAttend) => retreatAttend.inOutInfos) - retreatAttend: RetreatAttend + retreatAttend!: RetreatAttend @Column() - day: Days + day!: Days @Column() - time: string + time!: string @Column() - inOutType: InOutType + inOutType!: InOutType @Column() - position: string + position!: string @Column() - howToMove: HowToMove + howToMove!: HowToMove @Column({ default: false }) - autoCreated: boolean + autoCreated!: boolean @ManyToOne(() => InOutInfo, (inOutInfo) => inOutInfo.userInTheCar) - rideCarInfo: InOutInfo | null + rideCarInfo!: InOutInfo | null @OneToMany(() => InOutInfo, (inOutInfo) => inOutInfo.rideCarInfo) - userInTheCar: InOutInfo[] + userInTheCar!: InOutInfo[] } diff --git a/server/src/entity/retreat/retreatAttend.ts b/server/src/entity/retreat/retreatAttend.ts index 6bd6f3b6..0cf053c0 100644 --- a/server/src/entity/retreat/retreatAttend.ts +++ b/server/src/entity/retreat/retreatAttend.ts @@ -14,57 +14,57 @@ import { CurrentStatus, Deposit, HowToMove } from "../types" @Entity() export class RetreatAttend { @PrimaryGeneratedColumn("uuid") - id: string + id!: string @OneToOne(() => User, (user) => user.retreatAttend) @JoinColumn() - user: User + user!: User @Column({ default: 0 }) - groupNumber: number + groupNumber!: number @Column({ nullable: true }) - roomNumber: number + roomNumber!: number @Column({ type: "text", nullable: true }) - memo: string + memo!: string @Column({ default: Deposit.none }) - isDeposited: Deposit + isDeposited!: Deposit @Column({ nullable: true }) - howToGo: HowToMove + howToGo!: HowToMove @Column({ nullable: true }) - howToBack: HowToMove + howToBack!: HowToMove @Column({ default: false }) - isCanceled: boolean + isCanceled!: boolean @Column({ nullable: true }) - etc: string + etc!: string @Column({ default: CurrentStatus.null }) - currentStatus: CurrentStatus + currentStatus!: CurrentStatus @Column({ default: 0 }) - attendanceNumber: number + attendanceNumber!: number @Column({ type: "text", nullable: true }) - postcardContent: string + postcardContent!: string @OneToMany(() => InOutInfo, (inOutInfo) => inOutInfo.retreatAttend) - inOutInfos: InOutInfo[] + inOutInfos!: InOutInfo[] @Column({ default: true }) - isWorker: boolean + isWorker!: boolean @Column({ default: false }) - isHalf: boolean + isHalf!: boolean @CreateDateColumn({ type: "timestamp", default: () => "CURRENT_TIMESTAMP(6)", }) - createAt: Date + createAt!: Date } diff --git a/server/src/entity/retreat/sharing.ts b/server/src/entity/retreat/sharing.ts index 75aac2bf..dc9aa71c 100644 --- a/server/src/entity/retreat/sharing.ts +++ b/server/src/entity/retreat/sharing.ts @@ -11,65 +11,65 @@ import { User } from "../user" @Entity() export class SharingText { @PrimaryGeneratedColumn() - id: number + id!: number @ManyToOne(() => User, (user) => user.id) - writer: User + writer!: User @Column({ type: "text" }) - content: string + content!: string @Column({ default: true }) - visible: boolean + visible!: boolean @CreateDateColumn({ type: "timestamp", default: () => "CURRENT_TIMESTAMP(6)", }) - createAt: Date + createAt!: Date } @Entity() export class SharingImage { @PrimaryGeneratedColumn() - id: number + id!: number @ManyToOne(() => User, (user) => user.id) - writer: User + writer!: User @Column({ type: "text" }) - url: string + url!: string @Column({ default: true }) - visible: boolean + visible!: boolean @Column({ type: "text", nullable: true }) - tags: string + tags!: string @CreateDateColumn({ type: "timestamp", default: () => "CURRENT_TIMESTAMP(6)", }) - createAt: Date + createAt!: Date } @Entity() export class SharingVideo { @PrimaryGeneratedColumn() - id: number + id!: number @ManyToOne(() => User, (user) => user.id) - writer: User + writer!: User @Column({ type: "text" }) - url: string + url!: string @Column({ default: true }) - visible: boolean + visible!: boolean @CreateDateColumn({ type: "timestamp", default: () => "CURRENT_TIMESTAMP(6)", }) - createAt: Date + createAt!: Date } diff --git a/server/src/entity/user.ts b/server/src/entity/user.ts index e3865a92..b59cbb4a 100644 --- a/server/src/entity/user.ts +++ b/server/src/entity/user.ts @@ -15,22 +15,22 @@ import { RetreatAttend } from "./retreat/retreatAttend" @Entity() export class User { @PrimaryGeneratedColumn("uuid") - id: string + id!: string @Column({ nullable: true }) - kakaoId: string + kakaoId!: string @Column({ nullable: true }) - name: string + name!: string @Column({ nullable: true }) - yearOfBirth: number + yearOfBirth!: number @Column({ nullable: true }) - gender: "man" | "woman" | "" + gender!: "man" | "woman" | "" @Column({ nullable: true }) - phone: string + phone!: string @Column({ nullable: true, @@ -38,37 +38,37 @@ export class User { etc?: string @Column({ nullable: true, select: false }) - token: string + token!: string @Column({ nullable: true, select: false }) - expire: Date + expire!: Date @Column({ nullable: true, default: 0 }) - isSuperUser: boolean + isSuperUser!: boolean @CreateDateColumn({ type: "timestamp", default: () => "CURRENT_TIMESTAMP(6)", }) - createAt: Date + createAt!: Date @DeleteDateColumn({ type: "timestamp", nullable: true, }) - deletedAt: Date | null + deletedAt!: Date | null @OneToMany(() => Permission, (permission) => permission.user) - permissions: Permission[] + permissions!: Permission[] @Column({ default: 0 }) - profile: number + profile!: number @ManyToOne(() => Community, (community) => community.users) - community: Community | null + community!: Community | null @OneToOne(() => RetreatAttend, (retreatAttend) => retreatAttend.user, { nullable: true, }) - retreatAttend: RetreatAttend | null + retreatAttend!: RetreatAttend | null } diff --git a/server/src/entity/worshipSchedule.ts b/server/src/entity/worshipSchedule.ts index 6e7554cf..a8c51246 100644 --- a/server/src/entity/worshipSchedule.ts +++ b/server/src/entity/worshipSchedule.ts @@ -15,30 +15,30 @@ export enum WorshipKind { @Entity() export class WorshipSchedule { @PrimaryGeneratedColumn() - id: number + id!: number @Column({ type: "int", nullable: false }) - kind: WorshipKind + kind!: WorshipKind @Column() - date: string + date!: string @Column({ default: true }) - canEdit: boolean + canEdit!: boolean @Column({ default: true }) - isVisible: boolean + isVisible!: boolean @CreateDateColumn({ type: "timestamp", default: () => "CURRENT_TIMESTAMP(6)", }) - createdAt: string + createdAt!: string @UpdateDateColumn({ type: "timestamp", default: () => "CURRENT_TIMESTAMP(6)", onUpdate: "CURRENT_TIMESTAMP(6)", }) - lastModifiedAt: string + lastModifiedAt!: string } diff --git a/server/src/index.ts b/server/src/index.ts index 3e0fe556..7acb5f24 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,7 +10,7 @@ import cookieParser from "cookie-parser" const app = express() let port = 8000 -app.use(bodyParser.json()) +app.use(bodyParser.json({ limit: "100kb" })) app.use(cookieParser()) app.use( cors({ @@ -58,6 +58,8 @@ if (target === "local") { const credentials = { key: privateKey, cert: certificate, ca: ca } server = https.createServer(credentials, app) +} else { + throw new Error("Invalid API target") } server.listen(port, async () => { diff --git a/server/src/migration/1778997293189-ResetCommunityTablesCli.ts b/server/src/migration/1778997293189-ResetCommunityTablesCli.ts new file mode 100644 index 00000000..a1883719 --- /dev/null +++ b/server/src/migration/1778997293189-ResetCommunityTablesCli.ts @@ -0,0 +1,166 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class ResetCommunityTablesCli1778997293189 implements MigrationInterface { + public async up(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 \`post\``) + await queryRunner.query(`DROP TABLE IF EXISTS \`board\``) + + // Create boards + await queryRunner.query(` + CREATE TABLE \`board\` ( + \`id\` varchar(36) NOT NULL, + \`name\` varchar(255) COLLATE utf8mb4_general_ci NOT NULL COMMENT '게시판 이름', + \`slug\` varchar(255) COLLATE utf8mb4_general_ci NOT NULL COMMENT '게시판 고유 식별용 slug', + \`description\` text COLLATE utf8mb4_general_ci NULL COMMENT '게시판 설명', + \`visibility\` enum ('public','members','private') NOT NULL DEFAULT 'public' COMMENT '게시판 공개 범위', + \`createdById\` varchar(36) COLLATE utf8mb4_general_ci NULL, + \`type\` enum ('free','qna') NOT NULL DEFAULT 'free' COMMENT '게시판 유형', + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updatedAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + \`deletedAt\` timestamp NULL COMMENT '소프트 삭제 시각', + PRIMARY KEY (\`id\`), + UNIQUE INDEX \`IDX_board_slug\` (\`slug\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + + // Single-table inheritance for posts (base table contains child columns) + await queryRunner.query(` + CREATE TABLE \`post\` ( + \`id\` varchar(36) NOT NULL, + \`type\` varchar(50) NOT NULL DEFAULT 'free' COMMENT '게시글 타입', + \`authorId\` varchar(36) COLLATE utf8mb4_general_ci NOT NULL, + \`boardId\` varchar(36) COLLATE utf8mb4_general_ci NOT NULL, + \`title\` varchar(255) COLLATE utf8mb4_general_ci NULL COMMENT '게시글 제목', + \`content\` text COLLATE utf8mb4_general_ci NULL COMMENT '게시글 본문', + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updatedAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + \`deletedAt\` timestamp NULL, + PRIMARY KEY (\`id\`), + INDEX \`IDX_post_board_createdAt\` (\`boardId\`, \`createdAt\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + + // Join table for qna post + await queryRunner.query(` + CREATE TABLE \`qna_post\` ( + \`id\` varchar(36) NOT NULL, + \`postId\` varchar(36) COLLATE utf8mb4_general_ci NOT NULL, + \`answer\` text COLLATE utf8mb4_general_ci NULL COMMENT '관리자 답변', + \`answeredById\` varchar(36) COLLATE utf8mb4_general_ci NULL, + \`answeredAt\` timestamp NULL COMMENT '답변 완료 시각', + \`answerPublic\` tinyint(1) NOT NULL DEFAULT 0 COMMENT '답변 공개 여부', + PRIMARY KEY (\`id\`), + UNIQUE INDEX \`IDX_qna_post_postId\` (\`postId\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + + // comments + 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 NOT NULL, + \`content\` text COLLATE utf8mb4_general_ci NOT NULL COMMENT '댓글 내용', + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`deletedAt\` timestamp NULL COMMENT '소프트 삭제 시각', + PRIMARY KEY (\`id\`), + INDEX \`IDX_comment_post_createdAt\` (\`postId\`, \`createdAt\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + + // reactions + 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(255) COLLATE utf8mb4_general_ci NOT NULL COMMENT '반응 타입', + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + UNIQUE INDEX \`IDX_reaction_unique\` (\`postId\`, \`userId\`, \`type\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + + // Foreign keys + await queryRunner.query( + `ALTER TABLE \`board\` ADD CONSTRAINT \`FK_board_createdBy\` FOREIGN KEY (\`createdById\`) REFERENCES \`user\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION`, + ) + + await queryRunner.query( + `ALTER TABLE \`post\` ADD CONSTRAINT \`FK_post_author\` FOREIGN KEY (\`authorId\`) REFERENCES \`user\`(\`id\`) ON DELETE RESTRICT ON UPDATE NO ACTION`, + ) + await queryRunner.query( + `ALTER TABLE \`post\` ADD CONSTRAINT \`FK_post_board\` FOREIGN KEY (\`boardId\`) REFERENCES \`board\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ) + await queryRunner.query( + `ALTER TABLE \`qna_post\` ADD CONSTRAINT \`FK_qna_post_postId\` FOREIGN KEY (\`postId\`) REFERENCES \`post\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ) + await queryRunner.query( + `ALTER TABLE \`qna_post\` ADD CONSTRAINT \`FK_qna_post_answeredBy\` FOREIGN KEY (\`answeredById\`) REFERENCES \`user\`(\`id\`) ON DELETE RESTRICT ON UPDATE NO ACTION`, + ) + + await queryRunner.query( + `ALTER TABLE \`comment\` ADD CONSTRAINT \`FK_comment_post\` FOREIGN KEY (\`postId\`) REFERENCES \`post\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ) + await queryRunner.query( + `ALTER TABLE \`comment\` ADD CONSTRAINT \`FK_comment_parent\` FOREIGN KEY (\`parentId\`) REFERENCES \`comment\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION`, + ) + await queryRunner.query( + `ALTER TABLE \`comment\` ADD CONSTRAINT \`FK_comment_author\` FOREIGN KEY (\`authorId\`) REFERENCES \`user\`(\`id\`) ON DELETE RESTRICT ON UPDATE NO ACTION`, + ) + + await queryRunner.query( + `ALTER TABLE \`reaction\` ADD CONSTRAINT \`FK_reaction_post\` FOREIGN KEY (\`postId\`) REFERENCES \`post\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ) + await queryRunner.query( + `ALTER TABLE \`reaction\` ADD CONSTRAINT \`FK_reaction_user\` FOREIGN KEY (\`userId\`) REFERENCES \`user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`reaction\` DROP FOREIGN KEY \`FK_reaction_user\``, + ) + await queryRunner.query( + `ALTER TABLE \`reaction\` DROP FOREIGN KEY \`FK_reaction_post\``, + ) + + await queryRunner.query( + `ALTER TABLE \`comment\` DROP FOREIGN KEY \`FK_comment_author\``, + ) + await queryRunner.query( + `ALTER TABLE \`comment\` DROP FOREIGN KEY \`FK_comment_parent\``, + ) + await queryRunner.query( + `ALTER TABLE \`comment\` DROP FOREIGN KEY \`FK_comment_post\``, + ) + + await queryRunner.query( + `ALTER TABLE \`qna_post\` DROP FOREIGN KEY \`FK_qna_post_answeredBy\``, + ) + await queryRunner.query( + `ALTER TABLE \`qna_post\` DROP FOREIGN KEY \`FK_qna_post_postId\``, + ) + + await queryRunner.query( + `ALTER TABLE \`post\` DROP FOREIGN KEY \`FK_post_board\``, + ) + await queryRunner.query( + `ALTER TABLE \`post\` DROP FOREIGN KEY \`FK_post_author\``, + ) + + await queryRunner.query( + `ALTER TABLE \`board\` DROP FOREIGN KEY \`FK_board_createdBy\``, + ) + + 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 \`post\``) + await queryRunner.query(`DROP TABLE IF EXISTS \`board\``) + } +} diff --git a/server/src/migration/1780568400000-AddCommunityBoards.ts b/server/src/migration/1780568400000-AddCommunityBoards.ts deleted file mode 100644 index 48f036ed..00000000 --- a/server/src/migration/1780568400000-AddCommunityBoards.ts +++ /dev/null @@ -1,126 +0,0 @@ -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/community.ts b/server/src/model/community.ts new file mode 100644 index 00000000..f5aa3adc --- /dev/null +++ b/server/src/model/community.ts @@ -0,0 +1,426 @@ +import { In, IsNull } from "typeorm" +import { + boardDatabase, + commentDatabase, + postDatabase, + qnaPostDatabase, + reactionDatabase, +} from "./dataSource" +import { Board, BoardVisibility } from "../entity/community/board" +import { Comment } from "../entity/community/comment" +import { Post, PostType } from "../entity/community/post" +import { QnaPost } from "../entity/community/qnaPost" +import { Reaction } from "../entity/community/reaction" +import { User } from "../entity/user" + +export type BoardInput = { + name: string + slug: string + description?: string + visibility?: BoardVisibility + createdBy?: User | null + moderators?: User[] +} + +export type FreePostInput = { + boardId: string + author: User + title?: string + content?: string + isAnonymous?: boolean +} + +export type QnaPostInput = { + boardId: string + author: User + title?: string + isAnonymous?: boolean +} + +export type CommentInput = { + postId: string + author: User + parentId?: string | null + content: string + isAnonymous?: boolean +} + +export type ReactionInput = { + postId: string + user: User + type: string +} + +const communityModel = { + async listBoards(): Promise { + return boardDatabase.find({ + relations: { + createdBy: true, + }, + order: { + createdAt: "ASC", + }, + }) + }, + + async getBoardById(id: string): Promise { + return boardDatabase.findOne({ + where: { id }, + relations: { + createdBy: true, + }, + }) + }, + + async createBoard(input: BoardInput): Promise { + const board = boardDatabase.create({ + name: input.name, + slug: input.slug, + description: input.description, + visibility: input.visibility ?? BoardVisibility.PUBLIC, + createdBy: input.createdBy ?? null, + }) + return boardDatabase.save(board) + }, + + async updateBoard(id: string, data: Partial): Promise { + await boardDatabase.update(id, data) + return this.getBoardById(id) + }, + + async deleteBoard(id: string): Promise { + await boardDatabase.softDelete(id) + }, + + async listFreePosts( + boardId: string, + opts?: { limit?: number; page?: number }, + ): Promise { + const board = await boardDatabase.findOne({ where: { id: boardId } }) + if (!board) { + throw new Error("Board not found") + } + + const limit = opts?.limit ?? 20 + const page = Math.max((opts?.page ?? 1) - 1, 0) + return postDatabase.find({ + where: { + board: { id: boardId }, + deletedAt: IsNull(), + }, + relations: { + board: true, + author: true, + comments: { + author: true, + }, + reactions: { + user: true, + }, + }, + order: { + createdAt: "DESC", + }, + select: { + id: true, + type: true, + title: true, + content: true, + createdAt: true, + updatedAt: true, + board: { + id: true, + name: true, + slug: true, + }, + author: { + id: true, + name: true, + }, + comments: { + id: true, + content: true, + createdAt: true, + author: { + id: true, + name: true, + }, + }, + reactions: { + id: true, + type: true, + user: { + id: true, + name: true, + }, + }, + }, + take: limit, + skip: page * limit, + }) + }, + + /////////////////////////// 여기 상위로 함수들은 확인 됨 + async listQnaPosts( + boardId: string, + opts?: { limit?: number; page?: number }, + ): Promise { + const limit = opts?.limit ?? 20 + const page = Math.max((opts?.page ?? 1) - 1, 0) + return qnaPostDatabase.find({ + where: { + post: { + board: { id: boardId }, + deletedAt: IsNull(), + }, + }, + relations: { + post: { + board: true, + author: true, + comments: { + author: true, + }, + reactions: { + user: true, + }, + }, + answeredBy: true, + }, + order: { + post: { + createdAt: "DESC", + }, + }, + select: { + id: true, + answer: true, + answerPublic: true, + answeredAt: true, + post: { + id: true, + title: true, + content: true, + createdAt: true, + updatedAt: true, + board: { + id: true, + name: true, + slug: true, + }, + }, + answeredBy: { + id: true, + name: true, + }, + }, + take: limit, + skip: page * limit, + }) + }, + + async getPostById(id: string): Promise { + const post = await postDatabase.findOne({ + where: { id }, + relations: { + board: { + createdBy: true, + }, + author: true, + // avoid eager-loading all comments here for performance; use listComments + // comments: { author: true, children: true }, + reactions: { + user: true, + }, + }, + }) + + if (!post) { + return null + } + + return post + }, + + async createFreePost(input: FreePostInput): Promise { + const post = postDatabase.create({ + board: { id: input.boardId }, + author: input.author, + title: input.title, + content: input.content, + type: PostType.FREE, + }) + return postDatabase.save(post) + }, + + async createQnaPost(input: QnaPostInput): Promise { + const post = qnaPostDatabase.create({ + post: { + board: { id: input.boardId }, + author: input.author, + title: input.title, + }, + }) + return qnaPostDatabase.save(post) + }, + + async updatePost( + id: string, + data: Partial>, + ): Promise { + await postDatabase.update(id, data) + return this.getPostById(id) + }, + + async deletePost(id: string): Promise { + await postDatabase.softDelete(id) + }, + + async createComment(input: CommentInput): Promise { + const comment = commentDatabase.create({ + post: { id: input.postId }, + parent: input.parentId ? ({ id: input.parentId } as Comment) : null, + author: input.author, + content: input.content, + }) + return commentDatabase.save(comment) + }, + + async listComments( + postId: string, + opts?: { limit?: number; page?: number }, + ): Promise { + const limit = opts?.limit ?? 20 + const page = Math.max((opts?.page ?? 1) - 1, 0) + return commentDatabase.find({ + where: { + post: { id: postId }, + parent: IsNull(), + deletedAt: IsNull(), + }, + relations: { + author: true, + children: { + author: true, + }, + }, + order: { + createdAt: "ASC", + }, + take: limit, + skip: page * limit, + }) + }, + + async getCommentById(id: string): Promise { + return commentDatabase.findOne({ + where: { id }, + relations: { + post: { + board: true, + author: true, + }, + parent: true, + author: true, + children: true, + }, + }) + }, + + async deleteComment(id: string): Promise { + await commentDatabase.softDelete(id) + }, + + async answerQnaPost( + id: string, + data: { + answer?: string | null + answerPublic?: boolean + answeredBy?: User | null + }, + ): Promise { + const qnaPost = await qnaPostDatabase.findOne({ + where: { post: { id } }, + relations: { + answeredBy: true, + post: { + board: true, + author: true, + comments: { + author: true, + }, + reactions: { + user: true, + }, + }, + }, + }) + + if (!qnaPost) { + return null + } + + if (data.answer !== undefined) { + qnaPost.answer = data.answer + qnaPost.answeredAt = data.answer ? new Date() : null + } + if (data.answerPublic !== undefined) { + qnaPost.answerPublic = data.answerPublic + } + if (data.answeredBy !== undefined) { + qnaPost.answeredBy = data.answeredBy + if (data.answeredBy && !qnaPost.answeredAt) { + qnaPost.answeredAt = new Date() + } + } + + return qnaPostDatabase.save(qnaPost) + }, + + async createReaction(input: ReactionInput): Promise { + const reaction = reactionDatabase.create({ + post: { id: input.postId }, + user: input.user, + type: input.type, + }) + return reactionDatabase.save(reaction) + }, + + async deleteReaction( + postId: string, + userId: string, + type: string, + ): Promise { + const reaction = await reactionDatabase.findOne({ + where: { + post: { id: postId }, + user: { id: userId }, + type, + }, + }) + + if (reaction) { + await reactionDatabase.delete(reaction.id) + } + }, + + async toggleReaction(input: ReactionInput): Promise<{ created: boolean }> { + const foundReaction = await reactionDatabase.findOne({ + where: { + post: { id: input.postId }, + user: { id: input.user.id }, + type: input.type, + }, + }) + + if (foundReaction) { + await reactionDatabase.delete(foundReaction.id) + return { created: false } + } + + await this.createReaction(input) + return { created: true } + }, +} + +export default communityModel diff --git a/server/src/model/dataSource.ts b/server/src/model/dataSource.ts index 0528fb7d..98af9139 100644 --- a/server/src/model/dataSource.ts +++ b/server/src/model/dataSource.ts @@ -18,7 +18,6 @@ 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" @@ -48,7 +47,6 @@ 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) diff --git a/server/src/routes/communityRouter.ts b/server/src/routes/communityRouter.ts new file mode 100644 index 00000000..32148686 --- /dev/null +++ b/server/src/routes/communityRouter.ts @@ -0,0 +1,602 @@ +import express from "express" +import communityModel from "../model/community" +import { getUserFromToken, hasPermissionFromReq } from "../util/util" +import { PermissionType } from "../entity/types" +import { BoardVisibility } from "../entity/community/board" +import { PostType } from "../entity/community/post" + +const router = express.Router() + +async function getViewerAccess(req: express.Request) { + const user = await getUserFromToken(req) + const isAdmin = await hasPermissionFromReq(req, PermissionType.admin) + const canManageCommunity = await hasPermissionFromReq( + req, + PermissionType.communityManage, + ) + return { user, isAdmin, canManageCommunity } +} + +function canAccessBoardVisibility( + board: { + visibility: BoardVisibility + moderators?: Array<{ id: string }> + createdBy?: { id: string } | null + }, + user: { id: string } | null, + isAdmin: boolean, +) { + if (isAdmin) { + return true + } + if (board.visibility === BoardVisibility.PUBLIC) { + return true + } + if (board.visibility === BoardVisibility.MEMBERS) { + return !!user + } + if (!user) { + return false + } + + const isModerator = board.moderators?.some( + (moderator) => moderator.id === user.id, + ) + const isCreator = board.createdBy?.id === user.id + return !!isModerator || isCreator +} + +router.get("/boards", async (req, res) => { + try { + const { user, isAdmin } = await getViewerAccess(req) + const boards = await communityModel.listBoards() + + const filteredBoards = boards.filter((board) => { + if (isAdmin) { + return true + } + return canAccessBoardVisibility(board, user, false) + }) + + res.status(200).json(filteredBoards) + } catch (error) { + console.error("Error fetching boards:", error) + res.status(500).json({ error: "Failed to fetch boards" }) + } +}) + +router.get("/boards/:boardId", async (req, res) => { + try { + const { boardId } = req.params + const { user, isAdmin } = await getViewerAccess(req) + const board = await communityModel.getBoardById(boardId) + + if (!board) { + res.status(404).json({ error: "Board not found" }) + return + } + + if (!canAccessBoardVisibility(board, user, isAdmin)) { + res.status(403).json({ error: "Forbidden" }) + return + } + + res.status(200).json(board) + } catch (error) { + console.error("Error fetching board:", error) + res.status(500).json({ error: "Failed to fetch board" }) + } +}) + +router.post("/boards", async (req, res) => { + try { + const { user, canManageCommunity } = await getViewerAccess(req) + if (!user || !canManageCommunity) { + res.status(403).json({ error: "Forbidden" }) + return + } + + const { name, slug, description, visibility } = req.body + if (!name || !slug) { + res.status(400).json({ error: "Missing required fields: name, slug" }) + return + } + + const board = await communityModel.createBoard({ + name, + slug, + description, + visibility, + createdBy: user, + }) + + res.status(201).json(board) + } catch (error) { + console.error("Error creating board:", error) + res.status(500).json({ error: "Failed to create board" }) + } +}) + +router.put("/boards/:boardId", async (req, res) => { + try { + const { boardId } = req.params + const { user, canManageCommunity } = await getViewerAccess(req) + if (!user || !canManageCommunity) { + res.status(403).json({ error: "Forbidden" }) + return + } + + const board = await communityModel.updateBoard(boardId, req.body) + if (!board) { + res.status(404).json({ error: "Board not found" }) + return + } + + res.status(200).json(board) + } catch (error) { + console.error("Error updating board:", error) + res.status(500).json({ error: "Failed to update board" }) + } +}) + +router.delete("/boards/:boardId", async (req, res) => { + try { + const { boardId } = req.params + const { user, canManageCommunity } = await getViewerAccess(req) + if (!user || !canManageCommunity) { + res.status(403).json({ error: "Forbidden" }) + return + } + + await communityModel.deleteBoard(boardId) + res.status(200).json({ message: "Board deleted successfully" }) + } catch (error) { + console.error("Error deleting board:", error) + res.status(500).json({ error: "Failed to delete board" }) + } +}) + +router.get("/boards/:boardId/posts", async (req, res) => { + try { + const { boardId } = req.params + const { type } = req.query + const { user, isAdmin } = await getViewerAccess(req) + const board = await communityModel.getBoardById(boardId) + + if (!board) { + res.status(404).json({ error: "Board not found" }) + return + } + + if (!canAccessBoardVisibility(board, user, isAdmin)) { + res.status(403).json({ error: "Forbidden" }) + return + } + + const limit = Number(req.query.limit) || 20 + const page = Number(req.query.page) || 1 + const opts = { limit, page } + + if (type === PostType.QNA) { + const posts = await communityModel.listQnaPosts(boardId, opts) + res.status(200).json(posts) + return + } + + if (type === PostType.FREE) { + const posts = await communityModel.listFreePosts(boardId, opts) + res.status(200).json(posts) + return + } + + const [freePosts, qnaPosts] = await Promise.all([ + communityModel.listFreePosts(boardId, { ...opts }), + communityModel.listQnaPosts(boardId, opts), + ]) + res.status(200).json([...freePosts, ...qnaPosts]) + } catch (error) { + console.error("Error fetching board posts:", error) + res.status(500).json({ error: "Failed to fetch board posts" }) + } +}) + +router.post("/boards/:boardId/free-posts", async (req, res) => { + try { + const { boardId } = req.params + const { user, isAdmin } = await getViewerAccess(req) + const board = await communityModel.getBoardById(boardId) + + if (!board) { + res.status(404).json({ error: "Board not found" }) + return + } + + if (!canAccessBoardVisibility(board, user, isAdmin)) { + res.status(403).json({ error: "Forbidden" }) + return + } + + if (!user) { + res.status(401).json({ error: "Login required" }) + return + } + + const { title, content, isAnonymous } = req.body + const post = await communityModel.createFreePost({ + boardId, + author: user, + title, + content, + isAnonymous, + }) + + res.status(201).json(post) + } catch (error) { + console.error("Error creating free post:", error) + res.status(500).json({ error: "Failed to create free post" }) + } +}) + +router.post("/boards/:boardId/qna-posts", async (req, res) => { + try { + const { boardId } = req.params + const { user, isAdmin } = await getViewerAccess(req) + const board = await communityModel.getBoardById(boardId) + + if (!board) { + res.status(404).json({ error: "Board not found" }) + return + } + + if (!canAccessBoardVisibility(board, user, isAdmin)) { + res.status(403).json({ error: "Forbidden" }) + return + } + + if (!user) { + res.status(401).json({ error: "Login required" }) + return + } + + const { title, isAnonymous } = req.body + + const post = await communityModel.createQnaPost({ + boardId, + author: user, + title, + isAnonymous, + }) + + res.status(201).json(post) + } catch (error) { + console.error("Error creating qna post:", error) + res.status(500).json({ error: "Failed to create qna post" }) + } +}) + +router.get("/posts/:postId", async (req, res) => { + try { + const { postId } = req.params + const { user, isAdmin } = await getViewerAccess(req) + const post = await communityModel.getPostById(postId) + + if (!post) { + res.status(404).json({ error: "Post not found" }) + return + } + + const board = await communityModel.getBoardById(post.board.id) + if (!board) { + res.status(404).json({ error: "Board not found" }) + return + } + + if (!canAccessBoardVisibility(board, user, isAdmin)) { + res.status(403).json({ error: "Forbidden" }) + return + } + + // Do not include comments in post payload by default (use comments endpoint) + if (post.type === PostType.QNA) { + const qnaPost = post as any + const canSeeAnswer = + isAdmin || + (user && qnaPost.author?.id === user.id) || + qnaPost.answerPublic + + if (!canSeeAnswer) { + qnaPost.answer = null + qnaPost.answeredBy = null + } + } + + res.status(200).json(post) + } catch (error) { + console.error("Error fetching post:", error) + res.status(500).json({ error: "Failed to fetch post" }) + } +}) + +// GET paginated top-level comments for a post +router.get("/posts/:postId/comments", async (req, res) => { + try { + const { postId } = req.params + const { user, isAdmin } = await getViewerAccess(req) + const post = await communityModel.getPostById(postId) + + if (!post) { + res.status(404).json({ error: "Post not found" }) + return + } + + const board = await communityModel.getBoardById(post.board.id) + if (!board) { + res.status(404).json({ error: "Board not found" }) + return + } + + if (!canAccessBoardVisibility(board, user, isAdmin)) { + res.status(403).json({ error: "Forbidden" }) + return + } + + const limit = Number(req.query.limit) || 20 + const page = Number(req.query.page) || 1 + const comments = await communityModel.listComments(postId, { limit, page }) + res.status(200).json(comments) + } catch (error) { + console.error("Error fetching comments:", error) + res.status(500).json({ error: "Failed to fetch comments" }) + } +}) + +router.put("/posts/:postId", async (req, res) => { + try { + const { postId } = req.params + const { user, isAdmin } = await getViewerAccess(req) + const post = await communityModel.getPostById(postId) + + if (!post) { + res.status(404).json({ error: "Post not found" }) + return + } + + if (!user) { + res.status(401).json({ error: "Login required" }) + return + } + + const isOwner = post.author?.id === user.id + if (!isOwner && !isAdmin) { + res.status(403).json({ error: "Forbidden" }) + return + } + + const { title, content, isAnonymous } = req.body + const updateData: { + title?: string + content?: string + isAnonymous?: boolean + } = {} + + if (title !== undefined) { + updateData.title = title + } + if (content !== undefined) { + updateData.content = content + } + if (isAnonymous !== undefined) { + updateData.isAnonymous = Boolean(isAnonymous) + } + + const updated = await communityModel.updatePost(postId, updateData) + res.status(200).json(updated) + } catch (error) { + console.error("Error updating post:", error) + res.status(500).json({ error: "Failed to update post" }) + } +}) + +router.delete("/posts/:postId", async (req, res) => { + try { + const { postId } = req.params + const { user, isAdmin } = await getViewerAccess(req) + const post = await communityModel.getPostById(postId) + + if (!post) { + res.status(404).json({ error: "Post not found" }) + return + } + + if (!user) { + res.status(401).json({ error: "Login required" }) + return + } + + const isOwner = post.author?.id === user.id + if (!isOwner && !isAdmin) { + res.status(403).json({ error: "Forbidden" }) + return + } + + await communityModel.deletePost(postId) + res.status(200).json({ message: "Post deleted successfully" }) + } catch (error) { + console.error("Error deleting post:", error) + res.status(500).json({ error: "Failed to delete post" }) + } +}) + +router.post("/posts/:postId/comments", async (req, res) => { + try { + const { postId } = req.params + const { user, isAdmin } = await getViewerAccess(req) + const post = await communityModel.getPostById(postId) + + if (!post) { + res.status(404).json({ error: "Post not found" }) + return + } + + const board = await communityModel.getBoardById(post.board.id) + if (!board) { + res.status(404).json({ error: "Board not found" }) + return + } + + if (!canAccessBoardVisibility(board, user, isAdmin)) { + res.status(403).json({ error: "Forbidden" }) + return + } + + if (!user) { + res.status(401).json({ error: "Login required" }) + return + } + + const { content, parentId, isAnonymous } = req.body + if (!content) { + res.status(400).json({ error: "Missing required field: content" }) + return + } + + const comment = await communityModel.createComment({ + postId, + author: user, + parentId, + content, + isAnonymous, + }) + + res.status(201).json(comment) + } catch (error) { + console.error("Error creating comment:", error) + res.status(500).json({ error: "Failed to create comment" }) + } +}) + +router.delete("/comments/:commentId", async (req, res) => { + try { + const { commentId } = req.params + const { user, isAdmin } = await getViewerAccess(req) + + if (!user) { + res.status(401).json({ error: "Login required" }) + return + } + + const comment = await communityModel.getCommentById(commentId) + if (!comment) { + res.status(404).json({ error: "Comment not found" }) + return + } + + const isOwner = comment.author?.id === user.id + if (!isOwner && !isAdmin) { + res.status(403).json({ error: "Forbidden" }) + return + } + + await communityModel.deleteComment(commentId) + res.status(200).json({ message: "Comment deleted successfully" }) + } catch (error) { + console.error("Error deleting comment:", error) + res.status(500).json({ error: "Failed to delete comment" }) + } +}) + +router.post("/posts/:postId/reactions", async (req, res) => { + try { + const { postId } = req.params + const { user, isAdmin } = await getViewerAccess(req) + const post = await communityModel.getPostById(postId) + + if (!post) { + res.status(404).json({ error: "Post not found" }) + return + } + + const board = await communityModel.getBoardById(post.board.id) + if (!board) { + res.status(404).json({ error: "Board not found" }) + return + } + + if (!canAccessBoardVisibility(board, user, isAdmin)) { + res.status(403).json({ error: "Forbidden" }) + return + } + + if (!user) { + res.status(401).json({ error: "Login required" }) + return + } + + const { type = "like" } = req.body + const result = await communityModel.toggleReaction({ + postId, + user, + type, + }) + + res.status(200).json(result) + } catch (error) { + console.error("Error toggling reaction:", error) + res.status(500).json({ error: "Failed to toggle reaction" }) + } +}) + +router.delete("/posts/:postId/reactions", async (req, res) => { + try { + const { postId } = req.params + const { user } = await getViewerAccess(req) + + if (!user) { + res.status(401).json({ error: "Login required" }) + return + } + + const { type = "like" } = req.body + await communityModel.deleteReaction(postId, user.id, type) + res.status(200).json({ message: "Reaction deleted successfully" }) + } catch (error) { + console.error("Error deleting reaction:", error) + res.status(500).json({ error: "Failed to delete reaction" }) + } +}) + +router.post("/qna-posts/:postId/answer", async (req, res) => { + try { + const { postId } = req.params + const { user, canManageCommunity } = await getViewerAccess(req) + if (!user || !canManageCommunity) { + res.status(403).json({ error: "Forbidden" }) + return + } + + const post = await communityModel.getPostById(postId) + if (!post || post.type !== PostType.QNA) { + res.status(404).json({ error: "QnA post not found" }) + return + } + + const { answer, answerPublic } = req.body + const updated = await communityModel.answerQnaPost(postId, { + answer, + answerPublic, + answeredBy: user, + }) + + if (!updated) { + res.status(404).json({ error: "QnA post not found" }) + return + } + + res.status(200).json(updated) + } catch (error) { + console.error("Error answering qna post:", error) + res.status(500).json({ error: "Failed to answer qna post" }) + } +}) + +export default router diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 741d88a8..896086db 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -10,6 +10,7 @@ import soonRouter from "./soon/soonRouter" import newcomerRouter from "./newcomer/newcomerRouter" import eventRouter from "./event" import linkRouter from "./link" +import communityRouter from "./communityRouter" const router: Router = express.Router() @@ -25,5 +26,6 @@ router.use("/newcomer", newcomerRouter) router.use("/event", eventRouter) router.use("/link", linkRouter) +router.use("/community", communityRouter) export default router diff --git a/server/src/util/util.ts b/server/src/util/util.ts index 80a85b0e..04a95056 100644 --- a/server/src/util/util.ts +++ b/server/src/util/util.ts @@ -24,9 +24,14 @@ export async function hasPermission( return false } - const payload = jwt.verify(token, env.JWT_SECRET) as jwtPayload + let payload: jwtPayload + try { + payload = jwt.verify(token, env.JWT_SECRET) as jwtPayload + } catch (e) { + return false + } - if (payload.role.Admin) { + if (payload?.role?.Admin) { return true } diff --git a/server/tsconfig.json b/server/tsconfig.json index 35de78d2..0cb3a99f 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,13 +1,15 @@ { "compilerOptions": { - "lib": ["es5", "es6"], - "target": "es5", + "lib": ["es2015", "es2022", "dom"], // 💡 console 및 최신 문법(es2022)을 인식하도록 확장 + "types": ["node"], // 💡 require, process 등 Node.js 환경 타입을 강제 지정 + "target": "es2015", "module": "commonjs", "moduleResolution": "node", "emitDecoratorMetadata": true, "experimentalDecorators": true, "outDir": "./dist", "rootDir": "./src", - "esModuleInterop": true + "esModuleInterop": true, + "skipLibCheck": true // 💡 외부 라이브러리(TypeORM 등)의 자체 타입 에러 무시 (속도 향상) } }