diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..256883cf --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,27 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node +{ + "name": "Node.js & TypeScript", + "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm", + + // Prettier 패키지를 컨테이너에 '글로벌'로 설치합니다. + "postCreateCommand": "npm install -g prettier", + + "customizations": { + "vscode": { + "extensions": [ + // VS Code Prettier 확장 (esbenp.prettier-vscode) + "esbenp.prettier-vscode" + ], + "settings": { + // 파일을 저장할 때 자동 포맷팅 활성화 + "editor.formatOnSave": true, + // 기본 포맷터로 Prettier 설정 + "editor.defaultFormatter": "esbenp.prettier-vscode", + + // 💡 추가된 설정: Prettier 확장이 로컬이 아닌 글로벌 모듈을 찾도록 지시 + "prettier.resolveGlobalModules": true + } + } + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..16924d21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# 의존성 +/node_modules +/package-lock.json +/yarn.lock +/npm-debug.log* +/yarn-debug.log* +/yarn-error.log* +/pnpm-debug.log* + +# 빌드 출력 (TypeScript 컴파일 결과) +/dist +/build +/out +/es +/lib + +# TypeScript 캐시 +/.tsbuildinfo + +# 환경 변수 및 보안 파일 +.env +.env.local +.env.*.local + +# 운영체제/에디터 파일 +.DS_Store +Thumbs.db + +# VS Code 설정 +.vscode +/generated/prisma diff --git a/ArticleService.js b/ArticleService.js new file mode 100644 index 00000000..f0b3b8b5 --- /dev/null +++ b/ArticleService.js @@ -0,0 +1,116 @@ +// ArticleService.js +import axios from "axios"; +import { Article } from "./main.js"; + +const BASE_URL = "https://panda-market-api-crud.vercel.app"; + +/** + * 아티클 리스트 조회 + * @param {Object} params { page, pageSize, keyword } + * @returns {Promise} + */ +export async function getArticleList(params = {}) { + try { + const response = await axios.get(`${BASE_URL}/articles`, { params }); + // 응답 구조가 { list: [ {...} ], totalCount: … } 라는 가정 + const articles = response.data.list.map((item) => { + // 생성일이 있을수도 있고 없을수도 있으므로 기본값 처리 + const article = new Article( + item.title, + item.content, + item.writer || "알 수 없음" + ); + return article; + }); + return articles; + } catch (error) { + console.error("getArticleList 에러:", error.message); + throw error; + } +} + +/** + * 단일 아티클 조회 + * @param {string|number} id + * @returns {Promise
} + */ +export async function getArticle(id) { + try { + const response = await axios.get(`${BASE_URL}/articles/${id}`); + const { title, content, writer } = response.data; + const article = new Article(title, content, writer || "알 수 없음"); + return article; + } catch (error) { + console.error("getArticle 에러:", error.message); + throw error; + } +} + +/** + * 아티클 생성 + * @param {Object} data { title, content, image } + * @returns {Promise} + */ +export async function createArticle(data) { + try { + // 반드시 image 필드를 배열로 + const body = { + title: data.title, + content: data.content, + image: Array.isArray(data.image) ? data.image : [data.image], + }; + const response = await axios.post(`${BASE_URL}/articles`, body); + const { title, content, writer } = response.data; + const article = new Article(title, content, writer || "알 수 없음"); + console.log("createArticle 성공:", response.status); + return article; + } catch (error) { + const status = error.response ? error.response.status : "N/A"; + console.error(`createArticle 실패 (상태 코드: ${status}):`, error.message); + return null; + } +} + +/** + * 아티클 수정 + * @param {string|number} id + * @param {Object} data { title?, content?, image? } + * @returns {Promise} + */ +export async function patchArticle(id, data) { + try { + const body = {}; + if (data.title !== undefined) body.title = data.title; + if (data.content !== undefined) body.content = data.content; + if (data.image !== undefined) { + body.image = Array.isArray(data.image) ? data.image : [data.image]; + } + const response = await axios.patch(`${BASE_URL}/articles/${id}`, body); + const { title, content, writer } = response.data; + const article = new Article(title, content, writer || "알 수 없음"); + console.log("patchArticle 성공:", response.status); + return article; + } catch (error) { + const status = error.response ? error.response.status : "N/A"; + console.error(`patchArticle 실패 (상태 코드: ${status}):`, error.message); + return null; + } +} + +/** + * 아티클 삭제 + * @param {string|number} id + * @returns {Promise} 성공하면 true + */ +export async function deleteArticle(id) { + try { + const response = await axios.delete(`${BASE_URL}/articles/${id}`); + console.log("deleteArticle 성공:", response.status); + return true; + } catch (error) { + const status = error.response ? error.response.status : "N/A"; + console.error(`deleteArticle 실패 (상태 코드: ${status}):`, error.message); + return false; + } +} + diff --git a/ProductService.js b/ProductService.js new file mode 100644 index 00000000..bf8c00bb --- /dev/null +++ b/ProductService.js @@ -0,0 +1,48 @@ +import axios from "axios"; +import { Product, ElectronicProduct } from "./main.js"; + +const BASE_URL = "https://panda-market-api-crud.vercel.app"; + +export function validateProduct(productData) { + if (!productData) throw new Error("productData가 제공되지 않았습니다."); + const { name, description, price, tags, images } = productData; + + const missingFields = []; + if (!name) missingFields.push("name"); + if (!description) missingFields.push("description"); + if (price === undefined || price === null) missingFields.push("price"); + if (!tags) missingFields.push("tags"); + if (!images) missingFields.push("images"); + if (missingFields.length > 0) + throw new Error(`필수 필드가 누락되었습니다: ${missingFields.join(", ")}`); + + if (typeof name !== "string") throw new Error("name은 문자열이어야 합니다."); + if (typeof description !== "string") + throw new Error("description은 문자열이어야 합니다."); + if (typeof price !== "number" || price < 0) + throw new Error("price는 0 이상의 숫자여야 합니다."); + if (!Array.isArray(tags)) throw new Error("tags는 배열이어야 합니다."); + if (!Array.isArray(images)) throw new Error("images는 배열이어야 합니다."); +} + +export async function getProductList(params = {}) { + try { + const response = await axios.get(`${BASE_URL}/products`, { params }); + const products = response.data.list.map((p) => + p.tags.includes("전자제품") + ? new ElectronicProduct( + p.name, + p.description, + p.price, + p.tags, + p.images, + p.manufacturer + ) + : new Product(p.name, p.description, p.price, p.tags, p.images) + ); + return products; + } catch (error) { + console.error("상품 리스트 조회 실패:", error.message); + return []; + } +} diff --git a/README.md b/README.md new file mode 100644 index 00000000..ded0ecbc --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +{1팀} + +팀원 구성 + +- 신영석 +- 서석규 +- 이창호 +- 강희성 +- 이요한 + +기술 스택 + +- Backend: Express.js, PrismaORM +- Database: PostgresSQL +- etc: Git & Github, Discord, Notion diff --git a/algorithm/sorts.js b/algorithm/sorts.js new file mode 100644 index 00000000..b465d396 --- /dev/null +++ b/algorithm/sorts.js @@ -0,0 +1,96 @@ +// 선택 정렬 +function selectionSort(arr) { + for (let i = 0; i < arr.length - 1; i++) { + let minIndex = i; + + for (let j = i + 1; j < arr.length; j++) { + if (arr[j] < arr[minIndex]) { + minIndex = j; + } + } + + [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]]; + } +} + +// 삽입 정렬 +function insertionSort(arr) { + for (let i = 1; i < arr.length; i++) { + let current = arr[i]; + let j = i - 1; + + while (j >= 0 && arr[j] > current) { + arr[j + 1] = arr[j]; + j--; + } + + arr[j + 1] = current; + } +} + +// 병합 정렬 +function mergeSort(arr) { + if (arr.length <= 1) return arr; + + const middle = Math.floor(arr.length / 2); + + const left = mergeSort(arr.slice(0, middle)); + const right = mergeSort(arr.slice(middle)); + + return merge(left, right); +} + +function merge(left, right) { + const result = []; + + let leftIndex = 0; + let rightIndex = 0; + + while (leftIndex < left.length && rightIndex < right.length) { + if (left[leftIndex] < right[rightIndex]) { + result.push(left[leftIndex]); + leftIndex++; + } else { + result.push(right[rightIndex]); + rightIndex++; + } + } + + return result + .concat(left.slice(leftIndex)) + .concat(right.slice(rightIndex)); +} + +// 퀵 정렬 +function quickSort(arr, left = 0, right = arr.length - 1) { + if (left >= right) return; + + const pivotIndex = partition(arr, left, right); + + quickSort(arr, left, pivotIndex - 1); + quickSort(arr, pivotIndex + 1, right); +} + +function partition(arr, left, right) { + const pivot = arr[right]; + + let i = left - 1; + + for (let j = left; j < right; j++) { + if (arr[j] < pivot) { + i++; + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + } + + [arr[i + 1], arr[right]] = [arr[right], arr[i + 1]]; + + return i + 1; +} + +module.exports = { + selectionSort, + insertionSort, + mergeSort, + quickSort, +}; \ No newline at end of file diff --git a/algorithm/sorts.ts b/algorithm/sorts.ts new file mode 100644 index 00000000..59d462c3 --- /dev/null +++ b/algorithm/sorts.ts @@ -0,0 +1,97 @@ +// 선택 정렬 +export function selectionSort(arr: number[]): void { + for (let i = 0; i < arr.length - 1; i++) { + let minIndex = i; + + for (let j = i + 1; j < arr.length; j++) { + if (arr[j] < arr[minIndex]) { + minIndex = j; + } + } + + [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]]; + } +} + +// 삽입 정렬 +export function insertionSort(arr: number[]): void { + for (let i = 1; i < arr.length; i++) { + const current = arr[i]; + let j = i - 1; + + while (j >= 0 && arr[j] > current) { + arr[j + 1] = arr[j]; + j--; + } + + arr[j + 1] = current; + } +} + +// 병합 정렬 +export function mergeSort(arr: number[]): number[] { + if (arr.length <= 1) return arr; + + const middle = Math.floor(arr.length / 2); + + const left = mergeSort(arr.slice(0, middle)); + const right = mergeSort(arr.slice(middle)); + + return merge(left, right); +} + +function merge(left: number[], right: number[]): number[] { + const result: number[] = []; + + let leftIndex = 0; + let rightIndex = 0; + + while (leftIndex < left.length && rightIndex < right.length) { + if (left[leftIndex] < right[rightIndex]) { + result.push(left[leftIndex]); + leftIndex++; + } else { + result.push(right[rightIndex]); + rightIndex++; + } + } + + return result + .concat(left.slice(leftIndex)) + .concat(right.slice(rightIndex)); +} + +// 퀵 정렬 +export function quickSort( + arr: number[], + left: number = 0, + right: number = arr.length - 1 +): void { + if (left >= right) return; + + const pivotIndex = partition(arr, left, right); + + quickSort(arr, left, pivotIndex - 1); + quickSort(arr, pivotIndex + 1, right); +} + +function partition( + arr: number[], + left: number, + right: number +): number { + const pivot = arr[right]; + + let i = left - 1; + + for (let j = left; j < right; j++) { + if (arr[j] < pivot) { + i++; + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + } + + [arr[i + 1], arr[right]] = [arr[right], arr[i + 1]]; + + return i + 1; +} \ No newline at end of file diff --git a/helper.js b/helper.js new file mode 100644 index 00000000..61f867e2 --- /dev/null +++ b/helper.js @@ -0,0 +1,43 @@ +export const formatGroupResponse = (group) => { + const badges = []; + + if (group.participantCount >= 10) badges.push("참여자 10명 이상"); + if (group.like_count >= 100) badges.push("추천수 100 이상"); + + const recordCount = group.recordCount || 0; + + return { + id: group.id, + name: group.name, + description: group.description, + photoUrl: group.photoUrl, + goalRep: group.goalRep, + discordWebhookUrl: group.discordWebhookUrl, + discordInviteUrl: group.discordInviteUrl, + likeCount: group.like_count, + recordCount: recordCount, + tags: group.tags || [], + owner: group.owner + ? { + id: group.owner.id, + nickname: group.owner.nickname, + createdAt: new Date(group.owner.createdAt).getTime(), + updatedAt: new Date(group.owner.updatedAt).getTime(), + } + : null, + + participants: group.participants + ? group.participants.map((p) => ({ + id: p.id, + nickname: p.nickname, + createdAt: new Date(p.createdAt).getTime(), + updatedAt: new Date(p.updatedAt).getTime(), + })) + : [], + + createdAt: new Date(group.createdAt).getTime(), + updatedAt: new Date(group.updatedAt).getTime(), + + badges: badges, + }; +}; diff --git a/main.js b/main.js new file mode 100644 index 00000000..91a43b43 --- /dev/null +++ b/main.js @@ -0,0 +1,88 @@ +export class Product { + #favoriteCount; // private 필드로 캡슐화 + + constructor(name, description, price, tags = [], images = []) { + validateProduct(name, description, price, tags, images); + this.name = name; + this.description = description; + this.price = price; + this.tags = tags; + this.images = images; + this.#favoriteCount = 0; + } + + + favorite() { + this.#favoriteCount++; + } +} + +/** + * Product 데이터 검증 + * @param {Object} productData - 검증할 Product 데이터 + * @throws {Error} 검증 실패 시 에러 발생 + */ +function validateProduct(name, description, price, tags, images) { + // 필수 필드 존재 여부 확인 + const missingFields = []; + if (name === undefined) missingFields.push("name"); + if (description === undefined) missingFields.push("description"); + if (price === undefined || price === null) missingFields.push("price"); + if (!tags) missingFields.push("tags"); + if (!images) missingFields.push("images"); + + if (missingFields.length > 0) { + throw new Error(`필수 필드가 누락되었습니다: ${missingFields.join(", ")}`); + } + + // 데이터 타입 검증 + if (typeof name !== "string") { + throw new Error("name은 문자열이어야 합니다."); + } + if (typeof description !== "string") { + throw new Error("description은 문자열이어야 합니다."); + } + if (typeof price !== "number" || price < 0) { + throw new Error("price는 0 이상의 숫자여야 합니다."); + } + if (!Array.isArray(tags)) { + throw new Error("tags는 배열이어야 합니다."); + } + if (!Array.isArray(images)) { + throw new Error("images는 배열이어야 합니다."); + } +} + +// ElectronicProduct 클래스 - 상속 +export class ElectronicProduct extends Product { + constructor(name, description, price, tags = [], images = [], manufacturer) { + super(name, description, price, tags, images); + this.manufacturer = manufacturer; + } + + // 다형성: 부모 클래스의 메소드를 오버라이드할 수 있음 + favorite() { + super.favorite(); + console.log(`${this.manufacturer}의 ${this.name} 제품이 찜되었습니다.`); + } +} + +// Article 클래스 +export class Article { + #likeCount; // private 필드로 캡슐화 + #createdAt; // private 필드로 캡슐화 + + constructor(title, content, writer) { + this.title = title; + this.content = content; + this.writer = writer; + this.#likeCount = 0; + this.#createdAt = new Date(); // 현재 시간 저장 + } + + // 좋아요 메소드 + like() { + this.#likeCount++; + } +} + diff --git a/package.json b/package.json new file mode 100644 index 00000000..baa70c69 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "type": "module", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "@prisma/adapter-pg": "^7.0.0", + "@prisma/client": "^7.1.0", + "cors": "^2.8.5", + "dotenv": "^17.2.3", + "express": "^5.2.1" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/pg": "^8.15.6", + "prisma": "^7.0.0", + "ts-node": "^10.9.2" + } +} diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 00000000..9c5e9593 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +// This file was generated by Prisma and assumes you have installed the following: +// npm install --save-dev prisma dotenv +import "dotenv/config"; +import { defineConfig, env } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + datasource: { + url: env("DATABASE_URL"), + }, +}); diff --git a/server.js b/server.js new file mode 100644 index 00000000..d46568aa --- /dev/null +++ b/server.js @@ -0,0 +1,7 @@ +import app from "./src/app.js"; + +const PORT = process.env.PORT || 3000; + +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); +}); diff --git a/src/app.js b/src/app.js new file mode 100644 index 00000000..3a0d02b5 --- /dev/null +++ b/src/app.js @@ -0,0 +1,11 @@ +import express from "express"; +import router from "./routes/index.js"; +import cors from "cors"; + +const app = express(); + +app.use(express.json()); +app.use(cors()); +app.use("/api", router); + +export default app; diff --git a/src/modules/group/group-main.js b/src/modules/group/group-main.js new file mode 100644 index 00000000..e4120791 --- /dev/null +++ b/src/modules/group/group-main.js @@ -0,0 +1,211 @@ +// 그룹 crud 만들고 지우고 수정하고 조회하는 api +// 만들어야할것 post , get get , patch , delete +// 유져 id , name , description , photoUrl , discordWebhookUrl, likeCount , tags +// , createdAt, updatedAt, +// 오너 owner id , nickname , createdAt , updatedAt +// participants id, nickname, createdAt, updatedAt +// 이미지 멀터 만들기 +//헬퍼 사용 api 명세를 도저히 복잡해서 여기서는 못따라감 +//클래스 사용, 유효성 검사, 완료 스키마 변경요청 .. 안된다면 내꺼 변경 가능성 높은것들 주석표시 +//헬퍼js 사용시 api test 잘나오느것 확인 +// + +import { PrismaClient } from "@prisma/client"; +import multer from "multer"; +import path from "path"; +import { formatGroupResponse } from "../../utils/helpers.js"; +const prisma = new PrismaClient(); + +// 이미지 +export const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, "uploads/"), + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9); + cb(null, uniqueSuffix + path.extname(file.originalname)); + }, +}); + +export class GroupCRUD { + constructor() { + this.prisma = prisma; + } + createGroup = async (req, res) => { + try { + const { + owner_id, /////////////// //////////////////////////////////////////////////////////// + ownerNickname, /////////////////////////////////////////////////////////////////////////// + name, /////////////////////////////////////////////////////////////////////////// + tags, + goalRep, + photoUrl, + password, + description, + discordWebhookUrl, + discordInviteUrl, + } = req.body; + + if (!owner_id) + return res.status(400).send({ message: "아이디를 입력하세요" }); + if (!password) + return res.status(400).send({ message: "비밀번호를 입력하세요" }); + if (!name) + return res.status(400).send({ message: "그룹명을 입력하세요" }); + if (!ownerNickname) + return res.status(400).send({ message: "닉네임을 입력하세요" }); + + const newGroup = await this.prisma.group.create({ + data: { + owner_id, //////////////////////////////////////////////////////////// + ownerNickname, ///////////////////////////////////////////// + name, //////////////////////////////////////////////////////////// + tags, + goalRep, + photoUrl, + password, + description, + discordWebhookUrl, + discordInviteUrl, + like_count: 0, + participantCount: 1, + }, + }); + const response = { + ...formatGroupResponse(newGroup), + owner: { + id: owner_id, ///////////////////////////////////////////// + nickname: ownerNickname, ///////////////////////////////////////////// + createdAt: Date.now(), ///////////////////////////////////////////// + updatedAt: Date.now(), ///////////////////////////////////////////// + }, + participants: [], + badges: [], + }; + + res.status(201).send(response); + } catch (error) { + console.error(error); + res.status(500).send({ message: "그룹생성중 오류가 발생했습니다." }); + } + }; + + uploadimage = (file) => { + const imagePath = file.path.replace(/\\/g, "/"); + return { url: `https://localhost:3000/${imagePath}` }; + }; + + getGroup = async (req, res) => { + try { + const { page = 1, pageSize = 10, sortBy, keyword } = req.query; + let orderBy; + switch (sortBy) { + case "mostlike": + orderBy = { like_count: "desc" }; + break; + case "mostBadges": + orderBy = { participantCount: "desc" }; + break; + case "latest": + default: + orderBy = { createdAt: "desc" }; + } + const where = keyword ? { name: { contains: keyword } } : {}; + const [total, groups] = await Promise.all([ + this.prisma.group.count({ where }), + this.prisma.group.findMany({ + skip: (Number(page) - 1) * Number(pageSize), + take: Number(pageSize), + where, + orderBy, + include: { + owner: true, + participants: true, + }, + }), + ]); + const formattedData = groups.map((group) => formatGroupResponse(group)); + res.status(200).send({ + data: formattedData, + total: total, + }); + } catch (error) { + console.error(error); + res.status(500).send({ message: "그룹조회 실패" }); + } + }; + + getGroupDetail = async (req, res) => { + try { + const { groupId } = req.params; + const group = await this.prisma.group.findUnique({ + where: { id: Number(groupId) }, ///////////////////////////////////////////// + include: { + owner: true, + participants: true, + }, + }); + + if (!group) { + return res.status(404).send({ message: "그룹을 찾을 수 없습니다." }); + } + + const response = formatGroupResponse(group); + res.status(200).send(response); + } catch (error) { + console.error(error); + res.status(500).send({ message: "상세 조회 실패." }); + } + }; + + patchGroup = async (req, res) => { + try { + const { groupId } = req.params; + const { password, ...dataToUpdate } = req.body; + + const group = await this.prisma.group.findUnique({ + where: { id: Number(groupId) }, /////////////// + }); + + if (!group) return res.status(404).send({ message: "그룹이 없습니다." }); + if (group.password !== password) { + return res.status(403).send({ message: "비밀번호가 틀립니다" }); + } + const updatedGroup = await this.prisma.group.update({ + where: { id: Number(groupId) }, ///////////////////////////////////////////// + data: dataToUpdate, + include: { + owner: true, + participants: true, + }, + }); + + const response = formatGroupResponse(updatedGroup); + res.status(200).send(response); + } catch (error) { + console.error(error); + res.status(500).send({ message: "그룹 수정에 실패했습니다." }); + } + }; + + deleteGroup = async (req, res) => { + try { + const { groupId } = req.params; + const { ownerPassword } = req.body; + const group = await this.prisma.group.findUnique({ + where: { id: Number(groupId) }, ///////////////////////////////////////////// + }); + if (!group) { + return res.status(404).send({ message: "그룹이 없습니다." }); + } + if (group.password !== ownerPassword) { + return res.status(403).send({ message: "비밀번호가 틀립니다" }); + } + await this.prisma.group.delete({ + where: { id: Number(groupId) }, ///////////////////////////////////////////// + }); + res.status(204).send(); + } catch (error) { + console.error(error); + res.status(500).send({ message: "그룹 삭제 중 오류가 발생했습니다." }); + } + }; +} diff --git a/src/prisma/migrations/20251208055939_init/migration.sql b/src/prisma/migrations/20251208055939_init/migration.sql new file mode 100644 index 00000000..1ce53cb2 --- /dev/null +++ b/src/prisma/migrations/20251208055939_init/migration.sql @@ -0,0 +1,65 @@ +-- CreateTable +CREATE TABLE "Group" ( + "id" BIGSERIAL NOT NULL, + "name" TEXT NOT NULL, + "tags" TEXT[], + "goal_reps" INTEGER NOT NULL, + "image" TEXT, + "discord_web_url" TEXT NOT NULL, + "discord_server_url" TEXT NOT NULL, + "like_count" INTEGER NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6), + "owner_id" BIGINT NOT NULL, + + CONSTRAINT "Group_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" BIGSERIAL NOT NULL, + "name" TEXT NOT NULL, + "password" TEXT NOT NULL, + "group_id" BIGINT NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workout_log" ( + "id" BIGSERIAL NOT NULL, + "category" TEXT NOT NULL, + "description" TEXT NOT NULL, + "time" TIMESTAMP(3) NOT NULL, + "distance" INTEGER NOT NULL, + "images" TEXT[], + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_id" BIGINT NOT NULL, + + CONSTRAINT "workout_log_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Badge" ( + "id" BIGSERIAL NOT NULL, + "content" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "group_id" BIGINT NOT NULL, + + CONSTRAINT "Badge_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Group_owner_id_key" ON "Group"("owner_id"); + +-- AddForeignKey +ALTER TABLE "Group" ADD CONSTRAINT "Group_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workout_log" ADD CONSTRAINT "workout_log_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Badge" ADD CONSTRAINT "Badge_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/prisma/migrations/migration_lock.toml b/src/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/src/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/src/prisma/prisma.js b/src/prisma/prisma.js new file mode 100644 index 00000000..445e709e --- /dev/null +++ b/src/prisma/prisma.js @@ -0,0 +1,10 @@ +import "dotenv/config"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "../generated/prisma/client.js"; + +const connectionString = `${process.env.DATABASE_URL}`; + +const adapter = new PrismaPg({ connectionString }); +const prisma = new PrismaClient({ adapter }); + +export { prisma }; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma new file mode 100644 index 00000000..a4e3b1a5 --- /dev/null +++ b/src/prisma/schema.prisma @@ -0,0 +1,64 @@ +generator client { + provider = "prisma-client-js" + output = "../generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +model Group { + id BigInt @id @default(autoincrement()) + name String + tags String[] + goal_reps Int + image String? + discord_web_url String + discord_server_url String + like_count Int + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime? @updatedAt @db.Timestamptz(6) + + owner_id BigInt @unique + owner User @relation("GroupOwner", fields: [owner_id], references: [id]) + + users User[] + badges Badge[] +} + +model User { + id BigInt @id @default(autoincrement()) + name String + password String + + group_id BigInt + group Group @relation(fields: [group_id], references: [id], onDelete: Cascade) + + ownedGroup Group? @relation("GroupOwner") + + workoutLogs WorkoutLog[] +} + +model WorkoutLog { + id BigInt @id @default(autoincrement()) + category String + description String + time DateTime + distance Int + images String[] + created_at DateTime @default(now()) @db.Timestamptz(6) + + user_id BigInt + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + + @@map("workout_log") +} + +model Badge { + id BigInt @id @default(autoincrement()) + content String + created_at DateTime @default(now()) @db.Timestamptz(6) + + group_id BigInt + group Group @relation(fields: [group_id], references: [id], onDelete: Cascade) +} diff --git a/src/routers/groups/group-main.router.js b/src/routers/groups/group-main.router.js new file mode 100644 index 00000000..d9648436 --- /dev/null +++ b/src/routers/groups/group-main.router.js @@ -0,0 +1,15 @@ +import express from "express"; +import multer from "multer"; +import { GroupCRUD, storage } from "../../modules/groups/groups-main.js"; + +const router = express.Router(); + +const upload = multer({ storage: storage }); +const groupController = new GroupCRUD(); + +router.post("/", upload.single("image"), groupController.createGroup); +router.get("/", groupController.getGroups); +router.get("/:groupId", groupController.getGroupDetail); +router.patch("/:groupId", groupController.patchGroup); +router.delete("/:groupId", groupController.deleteGroup); +export default router; diff --git a/src/routers/index.js b/src/routers/index.js new file mode 100644 index 00000000..71cfff49 --- /dev/null +++ b/src/routers/index.js @@ -0,0 +1,8 @@ +import express from "express"; +import groupRouter from express.Router(); + +const router = express.Router(); + +router.use("/groups"); + +export default router; diff --git a/src/utils/helpers.js b/src/utils/helpers.js new file mode 100644 index 00000000..61f867e2 --- /dev/null +++ b/src/utils/helpers.js @@ -0,0 +1,43 @@ +export const formatGroupResponse = (group) => { + const badges = []; + + if (group.participantCount >= 10) badges.push("참여자 10명 이상"); + if (group.like_count >= 100) badges.push("추천수 100 이상"); + + const recordCount = group.recordCount || 0; + + return { + id: group.id, + name: group.name, + description: group.description, + photoUrl: group.photoUrl, + goalRep: group.goalRep, + discordWebhookUrl: group.discordWebhookUrl, + discordInviteUrl: group.discordInviteUrl, + likeCount: group.like_count, + recordCount: recordCount, + tags: group.tags || [], + owner: group.owner + ? { + id: group.owner.id, + nickname: group.owner.nickname, + createdAt: new Date(group.owner.createdAt).getTime(), + updatedAt: new Date(group.owner.updatedAt).getTime(), + } + : null, + + participants: group.participants + ? group.participants.map((p) => ({ + id: p.id, + nickname: p.nickname, + createdAt: new Date(p.createdAt).getTime(), + updatedAt: new Date(p.updatedAt).getTime(), + })) + : [], + + createdAt: new Date(group.createdAt).getTime(), + updatedAt: new Date(group.updatedAt).getTime(), + + badges: badges, + }; +}; diff --git a/test.js b/test.js new file mode 100644 index 00000000..b372eff0 --- /dev/null +++ b/test.js @@ -0,0 +1,180 @@ +const fakeGroupDB = []; + +const mockPrisma = { + group: { + create: async ({ data }) => { + const newEntry = { + id: fakeGroupDB.length + 1, + ...data, + createdAt: new Date(), + updatedAt: new Date(), + owner: { + id: data.owner_id, + nickname: data.ownerNickname, + createdAt: new Date(), + updatedAt: new Date(), + }, + participants: [], + }; + fakeGroupDB.push(newEntry); + return newEntry; + }, + findMany: async (args) => { + return fakeGroupDB; + }, + findUnique: async ({ where }) => { + return fakeGroupDB.find((g) => g.id === where.id) || null; + }, + update: async ({ where, data }) => { + const idx = fakeGroupDB.findIndex((g) => g.id === where.id); + if (idx === -1) throw new Error("Not Found"); + fakeGroupDB[idx] = { + ...fakeGroupDB[idx], + ...data, + updatedAt: new Date(), + }; + return fakeGroupDB[idx]; + }, + delete: async ({ where }) => { + const idx = fakeGroupDB.findIndex((g) => g.id === where.id); + if (idx !== -1) fakeGroupDB.splice(idx, 1); + return true; + }, + }, +}; + +class GroupCRUD { + constructor() { + this.prisma = mockPrisma; + } + + formatResponse(group) { + const badges = []; + if (group.participantCount >= 10) badges.push("회원 10명 이상"); + if (group.like_count >= 100) badges.push("추천 100개 이상"); + if (group.goalRep >= 100) badges.push("운동수 100개 이상"); + + return { + id: group.id, + name: group.name, + description: group.description, + photoUrl: group.photoUrl, + goalRep: group.goalRep, + discordWebhookUrl: group.discordWebhookUrl, + discordInviteUrl: group.discordInviteUrl, + likeCount: group.like_count, + tags: group.tags || [], + owner: group.owner + ? { + id: group.owner.id, + nickname: group.owner.nickname, + createdAt: new Date(group.owner.createdAt).getTime(), + updatedAt: new Date(group.owner.updatedAt).getTime(), + } + : null, + participants: group.participants + ? group.participants.map((p) => ({ + id: p.id, + nickname: p.nickname, + createdAt: new Date(p.createdAt).getTime(), + updatedAt: new Date(p.updatedAt).getTime(), + })) + : [], + createdAt: new Date(group.createdAt).getTime(), + updatedAt: new Date(group.updatedAt).getTime(), + badges: badges, + }; + } + + createGroup = async (req, res) => { + try { + const { + owner_id, + ownerNickname, + name, + tags, + goalRep, + photoUrl, + password, + description, + discordWebhookUrl, + discordInviteUrl, + } = req.body; + + const newGroup = await this.prisma.group.create({ + data: { + owner_id, + ownerNickname, + name, + tags, + goalRep, + photoUrl, + password, + description, + discordWebhookUrl, + discordInviteUrl, + like_count: 0, + participantCount: 1, + }, + }); + + const response = this.formatResponse(newGroup); + res.status(201).send(response); + } catch (error) { + console.error(error); + res.status(500).send({ message: "에러남" }); + } + }; + + getGroupDetail = async (req, res) => { + try { + const { groupId } = req.params; + const group = await this.prisma.group.findUnique({ + where: { id: Number(groupId) }, + }); + if (!group) return res.status(404).send({ message: "없음" }); + const response = this.formatResponse(group); + res.status(200).send(response); + } catch (error) { + console.error(error); + } + }; +} + +const controller = new GroupCRUD(); + +const mockRes = (label) => ({ + status: (code) => ({ + send: (data) => { + console.log(`\n--- [${label}] 결과 (Status: ${code}) ---`); + console.log(JSON.stringify(data, null, 2)); // 보기 좋게 출력 + }, + }), +}); + +async function runTest() { + console.log("시작"); + + const reqCreate = { + body: { + owner_id: 1, + ownerNickname: "Jemi", + name: "새벽 러닝 크루", + password: "1234", + description: "새벽에 뜁니다", + tags: ["러닝", "오운완"], + goalRep: 50, + photoUrl: "img.jpg", + discordWebhookUrl: "url", + discordInviteUrl: "invite", + }, + }; + await controller.createGroup(reqCreate, mockRes("그룹 생성 API")); + + const reqGet = { + params: { groupId: 1 }, + }; + await controller.getGroupDetail(reqGet, mockRes("그룹 상세 조회 API")); +} + +runTest();