diff --git a/feature3/config/db.config.js b/feature3/config/db.config.js new file mode 100644 index 00000000..b4b4e32b --- /dev/null +++ b/feature3/config/db.config.js @@ -0,0 +1,17 @@ +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export const pool = mysql.createPool({ + host: process.env.DB_HOST || 'umc-database-week7.cx64ak0w82c4.ap-northeast-2.rds.amazonaws.com', // mysql의 hostname + user: process.env.DB_USER || 'root', // user 이름 + port: process.env.DB_PORT || 3306, // 포트 번호 + database: process.env.DB_TABLE || 'umc-week9-liv', // 데이터베이스 이름 + password: process.env.DB_PASSWORD || 'Lhiso329k!', // 비밀번호 + waitForConnections: true, + // Pool에 획득할 수 있는 connection이 없을 때, + // true면 요청을 queue에 넣고 connection을 사용할 수 있게 되면 요청을 실행하며, false이면 즉시 오류를 내보내고 다시 요청 + connectionLimit: 10, // 몇 개의 커넥션을 가지게끔 할 것인지 + queueLimit: 0, // getConnection에서 오류가 발생하기 전에 Pool에 대기할 요청의 개수 한도 +}); \ No newline at end of file diff --git a/feature3/config/error.js b/feature3/config/error.js new file mode 100644 index 00000000..4e6be065 --- /dev/null +++ b/feature3/config/error.js @@ -0,0 +1,6 @@ +export class BaseError extends Error { + constructor(data){ + super(data.message); + this.data = data; + } +} \ No newline at end of file diff --git a/feature3/config/response.js b/feature3/config/response.js new file mode 100644 index 00000000..5c2ec8ae --- /dev/null +++ b/feature3/config/response.js @@ -0,0 +1,9 @@ +export const response = ({isSuccess, code, message, status}, result) => { + return { + isSuccess: isSuccess, + code: code, + message: message, + status: status, + result: result + } +}; \ No newline at end of file diff --git a/feature3/config/response.status.js b/feature3/config/response.status.js new file mode 100644 index 00000000..719ec826 --- /dev/null +++ b/feature3/config/response.status.js @@ -0,0 +1,28 @@ +import { StatusCodes } from "http-status-codes"; + +export const status = { + // success + SUCCESS: {status: StatusCodes.OK, "isSuccess": true, "code": 2000, "message": "success!"}, + + // error + // common err + INTERNAL_SERVER_ERROR: {status: StatusCodes.INTERNAL_SERVER_ERROR, "isSuccess": false, "code": "COMMON000", "message": "서버 에러, 관리자에게 문의 바랍니다." }, + BAD_REQUEST: {status: StatusCodes.BAD_REQUEST, "isSuccess": false, "code": "COMMON001", "message": "잘못된 요청입니다." }, + UNAUTHORIZED: {status: StatusCodes.UNAUTHORIZED, "isSuccess": false, "code": "COMMON002", "message": "권한이 잘못되었습니다." }, + METHOD_NOT_ALLOWED: {status: StatusCodes.METHOD_NOT_ALLOWED, "isSuccess": false, "code": "COMMON003", "message": "지원하지 않는 Http Method 입니다." }, + FORBIDDEN: {status: StatusCodes.FORBIDDEN, "isSuccess": false, "code": "COMMON004", "message": "금지된 요청입니다." }, + NOT_FOUND: {status: StatusCodes.NOT_FOUND, "isSuccess": false, "code": "COMMON005", "message": "요청한 페이지를 찾을 수 없습니다. 관리자에게 문의 바랍니다." }, + PARAMETER_IS_WRONG : {status : StatusCodes.PARAMETER_IS_WRONG, "isSuccess" : false, "code": "COMMON006", "message":"잘못된 파라미터가 전달되었습니다."}, + + // member err + MEMBER_NOT_FOUND: {status: StatusCodes.BAD_REQUEST, "isSuccess": false, "code": "MEMBER4001", "message": "사용자가 없습니다."}, + NICKNAME_NOT_EXIST: {status: StatusCodes.BAD_REQUEST, "isSuccess": false, "code": "MEMBER4002", "message": "닉네임은 필수입니다."}, + EMAIL_ALREADY_EXIST: {status: StatusCodes.BAD_REQUEST, "isSuccess": false, "code": "MEMBER4003", "message": "이미 가입된 이메일이 존재합니다."}, + + // store err + STORE_ALREADY_EXIST: {status: StatusCodes.BAD_REQUEST, "isSuccess": false, "code": "STORE4001", "message": "이미 추가된 가게입니다."}, + + // article err + ARTICLE_NOT_FOUND: {status: StatusCodes.NOT_FOUND, "isSuccess": false, "code": "ARTICLE4001", "message": "게시글이 없습니다."} + +}; \ No newline at end of file diff --git a/feature3/config/swagger.config.js b/feature3/config/swagger.config.js new file mode 100644 index 00000000..e3740e35 --- /dev/null +++ b/feature3/config/swagger.config.js @@ -0,0 +1,16 @@ +import SwaggerJsdoc from "swagger-jsdoc"; + +const options = { + definition: { + info: { + title: 'UMC Study API', + version: '1.0.0', + description: 'UMC Study API with express, API 설명' + }, + host: 'localhost:3000', + basepath: '../' + }, + apis: ['./src/routes/*.js', './swagger/*'] +}; + +export const specs = SwaggerJsdoc(options); \ No newline at end of file diff --git a/feature3/index.js b/feature3/index.js new file mode 100644 index 00000000..83693417 --- /dev/null +++ b/feature3/index.js @@ -0,0 +1,56 @@ +import express from 'express'; +import { response } from './config/response.js'; +import { BaseError } from './config/error.js'; +import { status } from './config/response.status.js'; +import dotenv from 'dotenv'; +import { specs } from './config/swagger.config.js'; +import SwaggerUi from 'swagger-ui-express'; +import cors from 'cors'; + +import { userRouter } from './src/routes/user.route.js'; +import { storeRouter } from './src/routes/store.route.js'; + + +dotenv.config(); // .env 파일 사용 (환경 변수 관리) + +const app = express(); +const port = 3000; + +// server setting - veiw, static, body-parser etc.. +app.set('port', process.env.PORT || 3000) // 서버 포트 지정 +app.use(cors()); // cors 방식 허용 +app.use(express.static('public')); // 정적 파일 접근 +app.use(express.json()); // request의 본문을 json으로 해석할 수 있도록 함 (JSON 형태의 요청 body를 파싱하기 위함) +app.use(express.urlencoded({extended: false})); // 단순 객체 문자열 형태로 본문 데이터 해석 +app.use(express.urlencoded({extended: false})); // 단순 객체 문자열 형태로 본문 데이터 해석 + +// swagger +app.use('/api-docs', SwaggerUi.serve, SwaggerUi.setup(specs)); + +// router setting +app.use('/user', userRouter); +app.use('/store', storeRouter); +app.use('/:storeId', storeRouter); +app.use('/:userId', userRouter); + + +// error handling +app.use((req, res, next) => { + const err = new BaseError(status.NOT_FOUND); + next(err); +}); + +app.use((err, req, res, next) => { + console.log(err.data.status); + console.log(err.data.message); + // 템플릿 엔진 변수 설정 + res.locals.message = err.data.message; + // 개발환경이면 에러를 출력하고 아니면 출력하지 않기 + res.locals.error = process.env.NODE_ENV !== 'production' ? err : {}; + console.log("error", err); + res.status(err.data.status || status.INTERNAL_SERVER_ERROR).send(response(err.data)); +}); + +app.listen(app.get('port'), () => { + console.log(`Example app listening on port ${app.get('port')}`); +}); \ No newline at end of file diff --git a/feature3/src/controllers/store.controller.js b/feature3/src/controllers/store.controller.js new file mode 100644 index 00000000..c46fa042 --- /dev/null +++ b/feature3/src/controllers/store.controller.js @@ -0,0 +1,35 @@ +import { response } from "../../config/response.js"; +import { status } from "../../config/response.status.js"; +import { joinStore, addStoreReview, addStoreMission } from "../services/store.service.js"; +import { getReview, getMission } from "../providers/store.provider.js"; + +export const storeSignin = async (req, res, next) => { + console.log("가게 추가를 요청하였습니다!"); + console.log("body:", req.body); // 값이 잘 들어오나 찍어보기 위한 테스트용 + + res.send(response(status.SUCCESS, await joinStore(req.body))); +} + +export const storeReview = async (req, res, next) => { + console.log("리뷰 추가를 요청하였습니다!"); + console.log("body:", req.body); // 값이 잘 들어오나 찍어보기 위한 테스트용 + + res.send(response(status.SUCCESS, await addStoreReview(req.body))); +} + +export const storeMission = async (req, res, next) => { + console.log("미션 추가를 요청하였습니다!"); + console.log("body:", req.body); // 값이 잘 들어오나 찍어보기 위한 테스트용 + + res.send(response(status.SUCCESS, await addStoreMission(req.body))); +} + +export const reviewPreview = async (req, res, next) => { + return res.send(response(status.SUCCESS, await getReview(req.params.storeId, req.query))); +} + +export const missionPreview = async (req, res, next) => { + console.log("미션 조회를 요청하였습니다!"); + + return res.send(response(status.SUCCESS, await getMission(req.params.storeId, req.query))); +} \ No newline at end of file diff --git a/feature3/src/dtos/store.dto.js b/feature3/src/dtos/store.dto.js new file mode 100644 index 00000000..e606ca08 --- /dev/null +++ b/feature3/src/dtos/store.dto.js @@ -0,0 +1,58 @@ +// sign in response DTO +export const signinResponseDTO = (store, region) => { + const regionName = []; + regionName.push(region[0][0].region_name); + return {"name": store[0].store_name, "phone": store[0].store_phone, "region": regionName}; +} + +// review response DTO +export const reviewResponseDTO = (review, store, user) => { + const storeName = []; + const userName = []; + storeName.push(store[0][0].store_name); + userName.push(user[0][0].user_name); + return {"score": review[0].score, "store": storeName, "user": userName}; +} + +// mission response DTO +export const MissionResponseDTO = (mission, store) => { + const storeName = []; + storeName.push(store[0][0].store_name); + return {"store": storeName, "reward": mission[0].reward}; +} + +// preview review response DTO +export const previewReviewResponseDTO = (data) => { + + const reviews = []; + + for (let i = 0; i < data.length; i++) { + reviews.push({ + "user_name": data[i].user_name, + "rate": data[i].rate, + "review_body": data[i].review_content, + "create_date": formatDate(data[i].created_at) + }) + } + return {"reviewData": reviews, "cursorId": data[data.length-1].review_id}; +} + +// preview mission response DTO +export const previewMissionResponseDTO = (data) => { + + const missions = []; + + for (let i = 0; i < data.length; i++) { + missions.push({ + "store_name": data[i].store_name, + "reward": data[i].reward, + "mission_spec": data[i].mission_spec, + "deadline": formatDate(data[i].deadline) + }) + } + return {"missionData": missions, "cursorId": data[data.length-1].mission_id}; +} + +const formatDate = (date) => { + return new Intl.DateTimeFormat('kr').format(new Date(date)).replaceAll(" ", "").slice(0, -1); +} \ No newline at end of file diff --git a/feature3/src/models/store.dao.js b/feature3/src/models/store.dao.js new file mode 100644 index 00000000..39e1fd35 --- /dev/null +++ b/feature3/src/models/store.dao.js @@ -0,0 +1,99 @@ +import { pool } from "../../config/db.config.js"; +import { BaseError } from "../../config/error.js"; +import { status } from "../../config/response.status.js"; +import { confirmPhone, insertStoreSql, getRegionToStoreID, getStoreID, } from "./store.sql.js"; +import { getReviewByReviewIdAtFirst, getReviewByReviewId, getMissionByMissionIdAtFirst, getMissionByMissionId } from "./store.sql.js"; + +// Store 데이터 삽입 +export const addStore = async (data) => { + try{ + const conn = await pool.getConnection(); + + const [confirm] = await pool.query(confirmPhone, data.phone); + if(confirm[0].isExistPhone){ + conn.release(); + return -1; + } + + const result = await pool.query(insertStoreSql, [data.name, data.addr, data.specAddr, data.phone, data.region]); + conn.release(); + return result[0].insertId; + + }catch (err) { + throw new BaseError(status.PARAMETER_IS_WRONG); + } +} + +// Store 정보 얻기 +export const getStore = async (storeId) => { + try { + const conn = await pool.getConnection(); + const [store] = await pool.query(getStoreID, storeId); + + console.log(store); + + if(store.length == 0){ + return -1; + } + + conn.release(); + return store; + + } catch (err) { + throw new BaseError(status.PARAMETER_IS_WRONG); + } +} + +// region 반환 +export const getStoreRegionToStoreID = async (storeId) => { + try { + const conn = await pool.getConnection(); + const region = await pool.query(getRegionToStoreID, storeId); + + conn.release(); + + return region; + } catch (err) { + throw new BaseError(status.PARAMETER_IS_WRONG); + } +} + +// review 반환 +export const getPreviewReview = async (cursorId, size, storeId) => { + try { + const conn = await pool.getConnection(); + + if(cursorId == "undefined" || typeof cursorId == "undefined" || cursorId == null){ + const [reviews] = await pool.query(getReviewByReviewIdAtFirst, [parseInt(storeId), parseInt(size)]); + conn.release(); + return reviews; + + }else{ + const [reviews] = await pool.query(getReviewByReviewId, [parseInt(storeId), parseInt(cursorId), parseInt(size)]); + conn.release(); + return reviews; + } + } catch (err) { + throw new BaseError(status.PARAMETER_IS_WRONG); + } +} + +// mission 반환 +export const getPreviewMission = async (cursorId, size, storeId) => { + try { + const conn = await pool.getConnection(); + + if(cursorId == "undefined" || typeof cursorId == "undefined" || cursorId == null){ + const [missions] = await pool.query(getMissionByMissionIdAtFirst, [parseInt(storeId), parseInt(size)]); + conn.release(); + return missions; + + }else{ + const [missions] = await pool.query(getMissionByMissionId, [parseInt(storeId), parseInt(cursorId), parseInt(size)]); + conn.release(); + return missions; + } + } catch (err) { + throw new BaseError(status.PARAMETER_IS_WRONG); + } +} \ No newline at end of file diff --git a/feature3/src/models/store.sql.js b/feature3/src/models/store.sql.js new file mode 100644 index 00000000..66138338 --- /dev/null +++ b/feature3/src/models/store.sql.js @@ -0,0 +1,48 @@ +export const insertStoreSql = "INSERT INTO store (store_name, store_address, store_spec_address, store_phone, region_id) VALUES (?, ?, ?, ?, ?);"; + +export const getStoreID = "SELECT * FROM store WHERE store_id = ?"; + +export const connectUser = "INSERT INTO review (store_id, user_id) VALUES (?, ?);"; + +export const confirmPhone = "SELECT EXISTS(SELECT 1 FROM store WHERE store_phone = ?) as isExistPhone"; + +export const getRegionToStoreID = +"SELECT region.region_name " ++ "FROM store JOIN region on store.region_id = region.region_id " ++ "WHERE store.store_id = ?;"; + +export const getStoreToReviewID = +"SELECT store.store_name " ++ "FROM review JOIN store on review.store_id = store.store_id " ++ "WHERE review.review_id = ?;"; + +export const getReviewToStoreID = +"SELECT review.id, review.user_id, review.store_id, user.user_name " ++ "FROM review JOIN user using review.user_id = user.user_id " ++ "WHERE review.store_id = ? ORDER BY review.user_id ASC;"; + + +export const getReviewByReviewId = +"SELECT u.user_name, u.user_id, r.review_id, r.rate, r.review_content, r.created_at " ++ "FROM review r JOIN user u on r.user_id = u.user_id " ++ "WHERE r.restaurant_id = ? AND r.review_id < ? " ++ "ORDER BY r.review_id DESC LIMIT ? ;" + +export const getReviewByReviewIdAtFirst = +"SELECT u.user_name, u.user_id, r.review_id, r.rate, r.review_content, r.created_at " ++ "FROM review r JOIN user u on r.user_id = u.user_id " ++ "WHERE r.restaurant_id = ? " ++ "ORDER BY r.review_id DESC LIMIT ? ;" + + +export const getMissionByMissionIdAtFirst = +"SELECT s.store_name, s.store_id, m.id, m.reward, m.mission_spec, m.deadline " ++ "FROM mission m JOIN store s on m.store_id = s.store_id " ++ "WHERE m.store_id = ? " ++ "ORDER BY m.id DESC LIMIT ? ;" + +export const getMissionByMissionId = +"SELECT s.store_name, s.store_id, m.id, m.reward, m.mission_spec, m.deadline " ++ "FROM mission m JOIN store s on m.store_id = s.store_id " ++ "WHERE m.store_id = ? AND m.id < ? " ++ "ORDER BY m.id DESC LIMIT ? ;" \ No newline at end of file diff --git a/feature3/src/providers/store.provider.js b/feature3/src/providers/store.provider.js new file mode 100644 index 00000000..06a3cc31 --- /dev/null +++ b/feature3/src/providers/store.provider.js @@ -0,0 +1,12 @@ +import { previewReviewResponseDTO, previewMissionResponseDTO } from "../dtos/store.dto.js" +import { getPreviewReview, getPreviewMission } from "../models/store.dao.js" + +export const getReview = async (storeId, query) => { + const {reviewId, size = 3} = query; + return previewReviewResponseDTO(await getPreviewReview(reviewId, size, storeId)); +} + +export const getMission = async (storeId, query) => { + const {missionId, size = 3} = query; + return previewMissionResponseDTO(await getPreviewMission(missionId, size, storeId)); +} \ No newline at end of file diff --git a/feature3/src/routes/store.route.js b/feature3/src/routes/store.route.js new file mode 100644 index 00000000..3c435aa6 --- /dev/null +++ b/feature3/src/routes/store.route.js @@ -0,0 +1,12 @@ +import express from "express"; +import asyncHandler from 'express-async-handler'; +import { storeSignin, storeReview, storeMission, reviewPreview, missionPreview } from "../controllers/store.controller.js"; + +export const storeRouter = express.Router({mergeParams: true}); + + +storeRouter.post('/signin', asyncHandler(storeSignin)); +storeRouter.post('/review', asyncHandler(storeReview)); +storeRouter.post('/mission', asyncHandler(storeMission)); +storeRouter.get('/reviews', asyncHandler(reviewPreview)); +storeRouter.get('/mission', asyncHandler(missionPreview)); \ No newline at end of file diff --git a/feature3/src/services/store.service.js b/feature3/src/services/store.service.js new file mode 100644 index 00000000..aa6b20a2 --- /dev/null +++ b/feature3/src/services/store.service.js @@ -0,0 +1,44 @@ +import { BaseError } from "../../config/error.js"; +import { status } from "../../config/response.status.js"; +import { signinResponseDTO, reviewResponseDTO, MissionResponseDTO } from "../dtos/store.dto.js" +import { addStore, getStore, getStoreRegionToStoreID } from "../models/store.dao.js"; +import { addReview, getReview, getStoreNameToReviewID, getUserNameToReviewID } from "../models/review.dao.js"; +import { addMission, getMission, getStoreNameToMissionID } from "../models/mission.dao.js"; + +export const joinStore = async (body) => { + + const joinStoreData = await addStore({ + 'name': body.name, + 'addr': body.addr, + 'specAddr': body.specAddr, + 'phone': body.phone, + 'region': body.region + }); + + if(joinStoreData == -1){ + throw new BaseError(status.STORE_ALREADY_EXIST); + }else{ + return signinResponseDTO(await getStore(joinStoreData), await getStoreRegionToStoreID(joinStoreData)); + } +} + +export const addStoreReview = async (body) => { + + const addStoreReviewData = await addReview({ + 'content': body.content, + 'score': body.score, + 'store': body.store, + 'user': body.user + }); + return reviewResponseDTO(await getReview(addStoreReviewData), await getStoreNameToReviewID(addStoreReviewData), await getUserNameToReviewID(addStoreReviewData)); +} + +export const addStoreMission = async (body) => { + + const addStoreMissionData = await addMission({ + 'store': body.store, + 'reward': body.reward, + 'content': body.content + }); + return MissionResponseDTO(await getMission(addStoreMissionData), await getStoreNameToMissionID(addStoreMissionData)); +} \ No newline at end of file diff --git a/feature3/swagger/storeMission.yaml b/feature3/swagger/storeMission.yaml new file mode 100644 index 00000000..264361e4 --- /dev/null +++ b/feature3/swagger/storeMission.yaml @@ -0,0 +1,187 @@ +paths: + /store/mission: + post: + tags: + - StoreMission + summary: 미션 추가 + parameters: + - name: addMission + in: body + required: true + schema: + properties: + store: + type: integer + description: 가게 id + example: 1 + reward: + type: integer + description: 보상 + example: 500 + content: + type: string + description: 미션 내용 + example: 미션1 + + responses: + '200': + description: 미션 추가 성공! + schema: + type: object + properties: + status: + type: integer + example: 200 + isSuccess: + type: boolean + example: true + code: + type: integer + example: 2000 + message: + type: string + example: "success!" + data: + type: object + example: { + "store": "가게1", + "reward": 500 + } + + '400': + description: 잘못된 요청 + schema: + type: object + properties: + status: + type: integer + example: 400 + isSuccess: + type: boolean + example: false + code: + type: integer + example: COMMON001 + message: + type: string + example: 잘못된 요청입니다 + + '500': + description: 서버 에러 + schema: + type: object + properties: + status: + type: integer + example: 500 + isSuccess: + type: boolean + example: false + code: + type: integer + example: COMMON000 + message: + type: string + example: 서버 에러, 관리자에게 문의 바랍니다. + + +paths: + /{storeId}/mission: + get: + tags: + - User + summary: 가게 별 미션 조회 + parameters: + - name: storeId + in: path + required: true + schema: + type: integer + - name: missionId + in: query + required: false + schema: + properties: + reviewId: + type: integer + - name: paging + in: query + required: true + schema: + properties: + size: + type: integer + + responses: + '200': + description: 리뷰 조회 성공! + schema: + type: object + properties: + status: + type: integer + example: 200 + isSuccess: + type: boolean + example: true + code: + type: integer + example: 2000 + message: + type: string + example: "success!" + data: + type: array + example: { + "reviewData": [ + { + "store_name": "가게1", + "score": 5, + "review_content": "리뷰1", + "create_date": "2024.01.01" + }, + { + "user_name": "가게2", + "score": 4.5, + "review_content": "리뷰2", + "create_date": "2024.01.02" + } + ], + "cursorId": 1 + } + + '400': + description: 잘못된 요청 + schema: + type: object + properties: + status: + type: integer + example: 400 + isSuccess: + type: boolean + example: false + code: + type: integer + example: COMMON001 + message: + type: string + example: 잘못된 요청입니다 + + '500': + description: 서버 에러 + schema: + type: object + properties: + status: + type: integer + example: 500 + isSuccess: + type: boolean + example: false + code: + type: integer + example: COMMON000 + message: + type: string + example: 서버 에러, 관리자에게 문의 바랍니다. \ No newline at end of file