├── functions ├── middlewares │ ├── validator │ │ ├── index.js │ │ └── communityValidator.js │ ├── validation.js │ ├── errorHandler.js │ ├── slackAPI.js │ └── auth.js ├── constants │ ├── jwt.js │ ├── swagger │ │ └── schemas │ │ │ ├── index.js │ │ │ ├── commonErrorSchema.js │ │ │ ├── noticeSchema.js │ │ │ └── communitySchema.js │ ├── dummyImages.js │ ├── statusCode.js │ └── responseMessage.js ├── api │ ├── routes │ │ ├── health │ │ │ ├── index.js │ │ │ └── healthCheckGET.js │ │ ├── recommendation │ │ │ ├── index.js │ │ │ └── recommendationGET.js │ │ ├── user │ │ │ ├── index.js │ │ │ ├── userNicknamePATCH.js │ │ │ ├── userGET.js │ │ │ └── userUpdateFcmTokenPUT.js │ │ ├── auth │ │ │ ├── index.js │ │ │ ├── userDELETE.js │ │ │ ├── reissueTokenPOST.js │ │ │ ├── signupPOST.js │ │ │ └── signinPOST.js │ │ ├── notice │ │ │ ├── index.js │ │ │ └── noticeGET.js │ │ ├── category │ │ │ ├── index.js │ │ │ ├── categoryNameGET.js │ │ │ ├── categoryGET.js │ │ │ ├── categoryPATCH.js │ │ │ ├── categoryOrderPATCH.js │ │ │ ├── categoryPOST.js │ │ │ ├── categoryDELETE.js │ │ │ ├── categoryContentSearchGET.js │ │ │ └── categoryContentGET.js │ │ ├── community │ │ │ ├── communityCategoriesGET.js │ │ │ ├── communityPostDELETE.js │ │ │ ├── communityReportPOST.js │ │ │ ├── communityPostPOST.js │ │ │ ├── communityPostGET.js │ │ │ ├── communityPostsGET.js │ │ │ ├── communityCategoryPostsGET.js │ │ │ └── index.js │ │ ├── content │ │ │ ├── index.js │ │ │ ├── contentCheckPATCH.js │ │ │ ├── contentUnseenListGET.js │ │ │ ├── contentSearchGET.js │ │ │ ├── contentCategoryGET.js │ │ │ ├── contentNotificationGET.js │ │ │ ├── contentRenamePATCH.js │ │ │ ├── contentRecentListGET.js │ │ │ ├── contentCategoryPATCH.js │ │ │ ├── contentDELETE.js │ │ │ ├── contentNotificationDELETE.js │ │ │ ├── contentListGET.js │ │ │ ├── contentNotificationPATCH.js │ │ │ └── contentPOST.js │ │ └── index.js │ └── index.js ├── .prettierrc.js ├── config │ ├── dbConfig.js │ └── swagger.js ├── lib │ ├── asyncWrapper.js │ ├── util.js │ ├── slackMessage.js │ ├── convertSnakeToCamel.js │ ├── pushServerHandlers.js │ ├── jwtHandlers.js │ ├── appleAuth.js │ └── kakaoAuth.js ├── db │ ├── index.js │ ├── notice.js │ ├── recommendation.js │ ├── db.js │ ├── user.js │ ├── category.js │ ├── categoryContent.js │ ├── community.js │ └── content.js ├── .eslintrc.js ├── index.js └── package.json ├── .firebaserc ├── .github ├── ISSUE_TEMPLATE │ ├── -feat-.md │ └── -bugfix-.md └── workflows │ ├── prod.yml │ └── dev.yml ├── firebase.json ├── test ├── recommendation.spec.js ├── user.spec.js ├── category.spec.js └── content.spec.js ├── .gitignore └── README.md /functions/middlewares/validator/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | communityValidator: require('./communityValidator'), 3 | }; 4 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "dev": "havit-wesopt29", 4 | "prod": "havit-production" 5 | }, 6 | "targets": {} 7 | } -------------------------------------------------------------------------------- /functions/constants/jwt.js: -------------------------------------------------------------------------------- 1 | const TOKEN_EXPIRED = -3; 2 | const TOKEN_INVALID = -2; 3 | 4 | module.exports = { 5 | TOKEN_EXPIRED, 6 | TOKEN_INVALID, 7 | }; -------------------------------------------------------------------------------- /functions/api/routes/health/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | router.get('/', require('./healthCheckGET')); 5 | 6 | module.exports = router; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/-feat-.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "[FEAT]" 3 | about: 기능 추가 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### 추가 내용 11 | 12 | ### 할 일 13 | - [ ] 14 | - [ ] 15 | -------------------------------------------------------------------------------- /functions/constants/swagger/schemas/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | commonErrorSchema: require('./commonErrorSchema'), 3 | noticeSchema: require('./noticeSchema'), 4 | communitySchema: require('./communitySchema'), 5 | } -------------------------------------------------------------------------------- /functions/constants/swagger/schemas/commonErrorSchema.js: -------------------------------------------------------------------------------- 1 | const internalServerErrorSchema = { 2 | $status: 500, 3 | $success: false, 4 | $message: "서버 내부 오류", 5 | }; 6 | 7 | module.exports = { 8 | internalServerErrorSchema 9 | } -------------------------------------------------------------------------------- /functions/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: true, 3 | jsxBracketSameLine: true, 4 | singleQuote: true, 5 | trailingComma: 'all', 6 | arrowParens: 'always', 7 | printWidth: 100, 8 | tabWidth: 2, 9 | }; 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/-bugfix-.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "[BUGFIX]" 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### 수정 내용 11 | 12 | ### 할 일 13 | - [ ] 14 | - [ ] 15 | -------------------------------------------------------------------------------- /functions/constants/dummyImages.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content_dummy: 'https://havit-bucket.s3.ap-northeast-2.amazonaws.com/havit_content_dummy.png', 3 | user_profile_dummy: 'https://havit-bucket.s3.ap-northeast-2.amazonaws.com/user_profile_dummy.png', 4 | }; 5 | -------------------------------------------------------------------------------- /functions/config/dbConfig.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | dotenv.config(); 3 | 4 | module.exports = { 5 | user: process.env.DB_USER, 6 | host: process.env.DB_HOST, 7 | database: process.env.DB_DB, 8 | password: process.env.DB_PASSWORD, 9 | }; 10 | -------------------------------------------------------------------------------- /functions/api/routes/recommendation/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { checkUser } = require('../../../middlewares/auth'); 4 | 5 | router.get('/', checkUser, require('./recommendationGET')); 6 | 7 | module.exports = router; -------------------------------------------------------------------------------- /functions/lib/asyncWrapper.js: -------------------------------------------------------------------------------- 1 | module.exports = (fn) => { 2 | return async (req, res, next) => { 3 | try { 4 | await fn(req, res, next); 5 | } catch (error) { 6 | next(error); 7 | } finally { 8 | if (req.dbConnection) { 9 | req.dbConnection.release(); 10 | } 11 | } 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /functions/constants/statusCode.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | OK: 200, 3 | CREATED: 201, 4 | NO_CONTENT: 204, 5 | BAD_REQUEST: 400, 6 | UNAUTHORIZED: 401, 7 | FORBIDDEN: 403, 8 | NOT_FOUND: 404, 9 | CONFLICT: 409, 10 | INTERNAL_SERVER_ERROR: 500, 11 | SERVICE_UNAVAILABLE: 503, 12 | DB_ERROR: 600, 13 | }; -------------------------------------------------------------------------------- /functions/db/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | categoryDB: require('./category'), 3 | contentDB: require('./content'), 4 | categoryContentDB: require('./categoryContent'), 5 | recommendationDB: require('./recommendation'), 6 | userDB: require('./user'), 7 | noticeDB: require('./notice'), 8 | communityDB: require('./community'), 9 | }; 10 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "predeploy": [ 4 | "npm --prefix \"$RESOURCE_DIR\" run lint" 5 | ], 6 | "postdeploy" : "./deployMessageToSlack.sh" 7 | }, 8 | "hosting": { 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "function": "api", 13 | "region": "asia-northeast3" 14 | } 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /functions/api/routes/user/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { checkUser } = require('../../../middlewares/auth'); 4 | 5 | router.get('/', checkUser, require('./userGET')); 6 | router.put('/fcm-token', checkUser, require('./userUpdateFcmTokenPUT')); 7 | router.patch('/', checkUser, require('./userNicknamePATCH')); 8 | 9 | module.exports = router; -------------------------------------------------------------------------------- /functions/constants/swagger/schemas/noticeSchema.js: -------------------------------------------------------------------------------- 1 | const responseNoticeSchema = { 2 | $status: 200, 3 | $success: true, 4 | $message: "공지사항 조회 성공", 5 | $data: [ 6 | { 7 | $title: "notice title", 8 | $url: "notice url", 9 | $createdAt: "2022-09-15" 10 | } 11 | ] 12 | }; 13 | 14 | module.exports = { 15 | responseNoticeSchema 16 | } -------------------------------------------------------------------------------- /functions/db/notice.js: -------------------------------------------------------------------------------- 1 | const convertSnakeToCamel = require('../lib/convertSnakeToCamel'); 2 | 3 | const getNotices = async (client) => { 4 | const { rows } = await client.query( 5 | ` 6 | SELECT title, url, created_at FROM "notice" 7 | WHERE is_deleted = FALSE 8 | ORDER BY created_at DESC 9 | ` 10 | ) 11 | return convertSnakeToCamel.keysToCamel(rows); 12 | } 13 | 14 | module.exports = { getNotices } -------------------------------------------------------------------------------- /functions/api/routes/auth/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { checkUser } = require('../../../middlewares/auth'); 4 | 5 | router.post('/signin', require('./signinPOST')); 6 | router.post('/signup', require('./signupPOST')); 7 | router.post('/token', require('./reissueTokenPOST')); 8 | router.delete('/user', checkUser, require('./userDELETE')); 9 | 10 | module.exports = router; -------------------------------------------------------------------------------- /functions/lib/util.js: -------------------------------------------------------------------------------- 1 | const util = { 2 | success: (status, message, data) => { 3 | return { 4 | status, 5 | success: true, 6 | message, 7 | data, 8 | }; 9 | }, 10 | fail: (status, message) => { 11 | return { 12 | status, 13 | success: false, 14 | message, 15 | }; 16 | }, 17 | }; 18 | 19 | module.exports = util; -------------------------------------------------------------------------------- /functions/db/recommendation.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const convertSnakeToCamel = require('../lib/convertSnakeToCamel'); 3 | 4 | const getAllRecommendations = async (client) => { 5 | const { rows } = await client.query( 6 | ` 7 | SELECT * 8 | FROM recommendation r 9 | ` 10 | ); 11 | return convertSnakeToCamel.keysToCamel(rows); 12 | }; 13 | 14 | module.exports = { getAllRecommendations }; -------------------------------------------------------------------------------- /functions/api/routes/notice/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | router.get( 5 | '/', require('./noticeGET') 6 | /** 7 | * #swagger.summary = "공지사항 전체 조회" 8 | * #swagger.responses[200] = { 9 | description: "공지사항 조회 성공", 10 | content: { 11 | "application/json": { 12 | schema:{ 13 | $ref: "#/components/schemas/responseNoticeSchema" 14 | } 15 | } 16 | } 17 | } 18 | */ 19 | ); 20 | 21 | module.exports = router; -------------------------------------------------------------------------------- /functions/middlewares/validation.js: -------------------------------------------------------------------------------- 1 | const { validationResult } = require('express-validator'); 2 | const responseMessage = require('../constants/responseMessage'); 3 | const statusCode = require('../constants/statusCode'); 4 | 5 | const validate = (req, res, next) => { 6 | const errors = validationResult(req); 7 | if (errors.isEmpty()) { 8 | return next(); 9 | } 10 | 11 | const validatorErrorMessage = errors.array()[1]?.msg ?? errors.array()[0]?.msg; 12 | 13 | const detailError = { 14 | statusCode: statusCode.BAD_REQUEST, 15 | responseMessage: responseMessage.NULL_VALUE, 16 | validatorErrorMessage, 17 | }; 18 | 19 | return next(detailError); 20 | }; 21 | 22 | module.exports = { validate }; 23 | -------------------------------------------------------------------------------- /functions/api/routes/category/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { checkUser } = require('../../../middlewares/auth'); 4 | 5 | router.post('/', checkUser, require('./categoryPOST')); 6 | router.get('/', checkUser, require('./categoryGET')); 7 | router.get('/name', checkUser, require('./categoryNameGET')); 8 | router.get('/:categoryId/search', checkUser, require('./categoryContentSearchGET')); 9 | router.get('/:categoryId', checkUser, require('./categoryContentGET')); 10 | router.patch('/order', checkUser, require('./categoryOrderPATCH')); 11 | router.patch('/:categoryId', checkUser, require('./categoryPATCH')); 12 | router.delete('/:categoryId', checkUser, require('./categoryDELETE')); 13 | 14 | module.exports = router; -------------------------------------------------------------------------------- /test/recommendation.spec.js: -------------------------------------------------------------------------------- 1 | const app = require('../functions/api/index'); 2 | const request = require('supertest'); 3 | const { expect } = require('chai'); 4 | const dotenv = require('dotenv'); 5 | dotenv.config(); 6 | 7 | describe('GET /recommendation', () => { 8 | it('카테고리 이미지 전체 조회 성공', done => { 9 | request(app) 10 | .get('/recommendation') 11 | .set('Content-Type', 'application/json') 12 | .set('x-auth-token', process.env.JWT_TOKEN) 13 | .expect(200) 14 | .expect('Content-Type', /json/) 15 | .then(res => { 16 | done(); 17 | }) 18 | .catch(err => { 19 | console.error("######Error >>", err); 20 | done(err); 21 | }) 22 | }); 23 | }); -------------------------------------------------------------------------------- /functions/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | commonjs: true, 5 | es2021: true, 6 | }, 7 | extends: ["eslint:recommended", "eslint-config-prettier"], 8 | parserOptions: { 9 | ecmaVersion: 12, 10 | }, 11 | rules: { 12 | "no-prototype-builtins": "off", 13 | "no-self-assign": "off", 14 | "no-empty": "off", 15 | "no-case-declarations": "off", 16 | "consistent-return": "off", 17 | "arrow-body-style": "off", 18 | camelcase: "off", 19 | quotes: "off", 20 | "no-unused-vars": "off", 21 | "comma-dangle": "off", 22 | "no-bitwise": "off", 23 | "no-use-before-define": "off", 24 | "no-extra-boolean-cast": "off", 25 | "no-empty-pattern": "off", 26 | curly: "off", 27 | "no-unreachable": "off", 28 | }, 29 | }; -------------------------------------------------------------------------------- /functions/api/routes/community/communityCategoriesGET.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { communityDB } = require('../../../db'); 6 | const asyncWrapper = require('../../../lib/asyncWrapper'); 7 | 8 | /** 9 | * @route GET /community/categories 10 | * @desc 커뮤니티 카테고리 조회 11 | * @access Public 12 | */ 13 | 14 | module.exports = asyncWrapper(async (req, res) => { 15 | const dbConnection = await db.connect(req); 16 | req.dbConnection = dbConnection; 17 | 18 | const communityCategories = await communityDB.getCommunityCategories(dbConnection); 19 | 20 | res.status(statusCode.OK).send( 21 | util.success(statusCode.OK, responseMessage.READ_COMMUNITY_CATEGORIES_SUCCESS, communityCategories), 22 | ); 23 | }); -------------------------------------------------------------------------------- /functions/lib/slackMessage.js: -------------------------------------------------------------------------------- 1 | const getCommunityReportMessage = (userId, postId, title, postUserId) => ` 2 | { 3 | "blocks": [ 4 | { 5 | "type": "header", 6 | "text": { 7 | "type": "plain_text", 8 | "text": "🚨 게시글 신고 알림 🚨", 9 | "emoji": true 10 | } 11 | }, 12 | { 13 | "type": "divider" 14 | }, 15 | { 16 | "type": "context", 17 | "elements": [ 18 | { 19 | "type": "mrkdwn", 20 | "text": "*신고 유저 ID*: ${userId} \n *신고 게시글 ID: ${postId} * \n *게시글 제목:* ${title} \n *게시글 작성자 ID*: ${postUserId}" 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | `; 27 | 28 | module.exports = { getCommunityReportMessage }; 29 | -------------------------------------------------------------------------------- /functions/api/routes/category/categoryNameGET.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const util = require('../../../lib/util'); 3 | const statusCode = require('../../../constants/statusCode'); 4 | const responseMessage = require('../../../constants/responseMessage'); 5 | const db = require('../../../db/db'); 6 | const { categoryDB } = require('../../../db'); 7 | const asyncWrapper = require('../../../lib/asyncWrapper'); 8 | 9 | /** 10 | * @route GET /category/name 11 | * @desc 카테고리 이름 조회 12 | * @access Private 13 | */ 14 | module.exports = asyncWrapper(async (req, res) => { 15 | const { userId } = req.user; 16 | 17 | const dbConnection = await db.connect(req); 18 | req.dbConnection = dbConnection; 19 | 20 | const categoryNames = await categoryDB.getCategoryNames(dbConnection, userId); 21 | const titles = _.map(categoryNames, 'title'); 22 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.READ_CATEGORY_NAME_SUCCESS, titles)); 23 | }); -------------------------------------------------------------------------------- /functions/middlewares/errorHandler.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const slackAPI = require('./slackAPI'); 3 | const util = require('../lib/util'); 4 | const statusCode = require('../constants/statusCode'); 5 | const responseMessage = require('../constants/responseMessage'); 6 | 7 | module.exports = (error, req, res, next) => { 8 | const errorStatusCode = error.statusCode ? error.statusCode : statusCode.INTERNAL_SERVER_ERROR; 9 | const errorResponseMessage = error.responseMessage ? error.responseMessage : responseMessage.INTERNAL_SERVER_ERROR; 10 | functions.logger.error(`[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl}`, `[CONTENT] ${error}`); 11 | console.log(error); 12 | const slackMessage = `[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl} ${req.user ? `uid:${req.user.userId}` : 'req.user 없음'} ${JSON.stringify(error)}`; 13 | slackAPI.sendMessageToSlack(slackMessage, slackAPI.WEB_HOOK_ERROR_MONITORING); 14 | 15 | res.status(errorStatusCode).send(util.fail(errorStatusCode, errorResponseMessage)); 16 | } -------------------------------------------------------------------------------- /functions/middlewares/slackAPI.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const axios = require('axios'); 3 | 4 | // 슬랙 Webhook에서 발급받은 endpoint를 .env 파일에서 끌어옴 5 | // endpoint 자체는 깃허브에 올라가면 안 되기 때문! 6 | const WEB_HOOK_ERROR_MONITORING = process.env.WEB_HOOK_ERROR_MONITORING; 7 | 8 | const sendMessageToSlack = ( 9 | message, 10 | apiEndPoint = WEB_HOOK_ERROR_MONITORING, 11 | isBlockMessage = false, 12 | ) => { 13 | // 슬랙 Webhook을 이용해 슬랙에 메시지를 보내는 코드 14 | const body = isBlockMessage ? message : { text: message }; 15 | 16 | try { 17 | axios 18 | .post(apiEndPoint, body) 19 | .then((response) => {}) 20 | .catch((e) => { 21 | throw e; 22 | }); 23 | } catch (e) { 24 | console.error(e); 25 | // 슬랙 Webhook 자체에서 에러가 났을 경우, 26 | // Firebase 콘솔에 에러를 찍는 코드 27 | functions.logger.error('[slackAPI 에러]', { error: e }); 28 | } 29 | }; 30 | 31 | // 이 파일에서 정의한 변수 / 함수를 export 해서, 다른 곳에서 사용할 수 있게 해주는 코드 32 | module.exports = { 33 | sendMessageToSlack, 34 | WEB_HOOK_ERROR_MONITORING, 35 | }; 36 | -------------------------------------------------------------------------------- /test/user.spec.js: -------------------------------------------------------------------------------- 1 | const app = require('../functions/api/index'); 2 | const request = require('supertest'); 3 | const { expect } = require('chai'); 4 | const dotenv = require('dotenv'); 5 | dotenv.config(); 6 | 7 | describe('GET /user', () => { 8 | it('유저 조회 성공', done => { 9 | request(app) 10 | .get('/user') 11 | .set('Content-Type', 'application/json') 12 | .set('x-auth-token', process.env.JWT_TOKEN) 13 | .expect(200) 14 | .expect('Content-Type', /json/) 15 | .then(res => { 16 | expect(res.body.data.nickname).to.equal('jobchae'); 17 | expect(res.body.data.totalContentNumber).to.equal(9); 18 | expect(res.body.data.totalCategoryNumber).to.equal(6); 19 | expect(res.body.data.totalSeenContentNumber).to.equal(1); 20 | done(); 21 | }) 22 | .catch(err => { 23 | console.error("######Error >>", err); 24 | done(err); 25 | }) 26 | }); 27 | }); -------------------------------------------------------------------------------- /functions/api/routes/health/healthCheckGET.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const slackAPI = require('../../../middlewares/slackAPI'); 3 | const util = require('../../../lib/util'); 4 | const statusCode = require('../../../constants/statusCode'); 5 | const responseMessage = require('../../../constants/responseMessage'); 6 | 7 | module.exports = async (req, res) => { 8 | 9 | try { 10 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.HEALTH_CHECK_SUCCESS)); 11 | 12 | } catch (error) { 13 | functions.logger.error(`[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl}`, `[CONTENT] ${error}`); 14 | console.log(error); 15 | const slackMessage = `[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl} ${req.user ? `uid:${req.user.userId}` : 'req.user 없음'} ${JSON.stringify(error)}`; 16 | slackAPI.sendMessageToSlack(slackMessage, slackAPI.WEB_HOOK_ERROR_MONITORING); 17 | 18 | res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, responseMessage.INTERNAL_SERVER_ERROR)); 19 | } 20 | }; -------------------------------------------------------------------------------- /functions/api/routes/category/categoryGET.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { categoryDB, categoryContentDB } = require('../../../db'); 6 | const asyncWrapper = require('../../../lib/asyncWrapper'); 7 | 8 | /** 9 | * @route GET /category 10 | * @desc 카테고리 전체 조회 11 | * @access Private 12 | */ 13 | module.exports = asyncWrapper(async (req, res) => { 14 | const { userId } = req.user; 15 | 16 | const dbConnection = await db.connect(req); 17 | req.dbConnection = dbConnection; 18 | 19 | let categories = await categoryDB.getAllCategories(dbConnection, userId); 20 | for (let category of categories) { 21 | const categoryContent = await categoryContentDB.getAllCategoryContentByFilter(dbConnection, userId, category.id, 'created_at'); 22 | category.contentNumber = categoryContent.length; 23 | } 24 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.READ_CATEGORY_SUCCESS, categories)); 25 | }); 26 | -------------------------------------------------------------------------------- /functions/index.js: -------------------------------------------------------------------------------- 1 | const admin = require('firebase-admin'); 2 | const devServiceAccount = require('./havit-wesopt29-firebase-adminsdk-mgljp-478046b091.json'); 3 | const prodServiceAccount = require('./havit-production-firebase-adminsdk-bypl1-d081cc62e4.json'); 4 | const dotenv = require('dotenv'); 5 | 6 | let path; 7 | const havitEnv = process.env.PROJECT_ID === 'havit-production' ? 'production' : 'development'; 8 | switch (havitEnv) { 9 | case "production": 10 | path = `${__dirname}/.env.prod`; 11 | break; 12 | case "development": 13 | path = `${__dirname}/.env.dev`; 14 | break; 15 | default: 16 | path = `${__dirname}/.env.dev`; 17 | } 18 | dotenv.config({ path: path }); 19 | 20 | let firebase; 21 | 22 | if (admin.apps.length === 0) { 23 | if (process.env.NODE_ENV === 'production') { 24 | firebase = admin.initializeApp({ 25 | credential: admin.credential.cert(prodServiceAccount), 26 | })} else { 27 | firebase = admin.initializeApp({ 28 | credential: admin.credential.cert(devServiceAccount), 29 | })} 30 | } else { 31 | firebase = admin.app(); 32 | } 33 | 34 | module.exports = { 35 | api: require('./api'), 36 | }; 37 | -------------------------------------------------------------------------------- /functions/api/routes/content/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { checkUser } = require('../../../middlewares/auth'); 4 | 5 | router.post('/', checkUser, require('./contentPOST')); 6 | router.get('/', checkUser, require('./contentListGET')); 7 | router.get('/:contentId/category', checkUser, require('./contentCategoryGET')); 8 | router.get('/search', checkUser, require('./contentSearchGET')); 9 | router.get('/recent', checkUser, require('./contentRecentListGET')); 10 | router.get('/unseen', checkUser, require('./contentUnseenListGET')); 11 | router.get('/notification', checkUser, require('./contentNotificationGET')); 12 | router.delete('/:contentId', checkUser, require('./contentDELETE')); 13 | router.patch('/category', checkUser, require('./contentCategoryPATCH')); 14 | router.patch('/title/:contentId', checkUser, require('./contentRenamePATCH')); 15 | router.patch('/check', checkUser, require('./contentCheckPATCH')); 16 | router.patch('/:contentId/notification', checkUser, require('./contentNotificationPATCH')); 17 | router.delete('/:contentId/notification', checkUser, require('./contentNotificationDELETE')); 18 | 19 | module.exports = router; -------------------------------------------------------------------------------- /functions/lib/convertSnakeToCamel.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | const toCamel = (s) => { 4 | return s.replace(/([-_][a-z])/gi, ($1) => { 5 | return $1.toUpperCase().replace('-', '').replace('_', ''); 6 | }); 7 | }; 8 | 9 | const isArray = function (a) { 10 | return Array.isArray(a); 11 | }; 12 | 13 | const isObject = function (o) { 14 | return o === Object(o) && !isArray(o) && typeof o !== 'function'; 15 | }; 16 | 17 | const keysToCamel = function (o) { 18 | if (isObject(o)) { 19 | const n = {}; 20 | 21 | Object.keys(o).forEach((k) => { 22 | n[toCamel(k)] = o[k]; 23 | }); 24 | 25 | return n; 26 | } else if (isArray(o)) { 27 | return o.map((i) => { 28 | return keysToCamel(i); 29 | }); 30 | } 31 | 32 | return o; 33 | }; 34 | const keysToSnake = function (o) { 35 | if (isObject(o)) { 36 | const n = {}; 37 | 38 | Object.keys(o).forEach((k) => { 39 | n[_.snakeCase(k)] = o[k]; 40 | }); 41 | 42 | return n; 43 | } else if (isArray(o)) { 44 | return o.map((i) => { 45 | return _.snakeCase(i); 46 | }); 47 | } 48 | 49 | return o; 50 | }; 51 | 52 | module.exports = { 53 | keysToCamel, 54 | keysToSnake, 55 | }; -------------------------------------------------------------------------------- /functions/config/swagger.js: -------------------------------------------------------------------------------- 1 | const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' }); 2 | const { commonErrorSchema, noticeSchema, communitySchema } = require('../constants/swagger/schemas'); 3 | const dotenv = require('dotenv'); 4 | dotenv.config({ path: '.env.dev' }); 5 | 6 | const options = { 7 | info: { 8 | title: 'HAVIT API Docs', 9 | description: 'HAVIT APP server API 문서입니다', 10 | }, 11 | host: 'http://localhost:5001', 12 | servers: [ 13 | { 14 | url: 'http://localhost:5001/havit-wesopt29/asia-northeast3/api', 15 | description: '로컬 개발환경 host', 16 | }, 17 | { 18 | url: process.env.DEV_HOST, 19 | description: '개발환경 host', 20 | }, 21 | ], 22 | schemes: ['http'], 23 | securityDefinitions: { 24 | bearerAuth: { 25 | type: 'http', 26 | name: 'x-auth-token', 27 | in: 'header', 28 | bearerFormat: 'JWT', 29 | }, 30 | }, 31 | components: { 32 | schemas: { 33 | ...commonErrorSchema, 34 | ...noticeSchema, 35 | ...communitySchema, 36 | }, 37 | }, 38 | }; 39 | 40 | const outputFile = '../constants/swagger/swagger-output.json'; 41 | const endpointsFiles = ['../api/index.js']; 42 | 43 | swaggerAutogen(outputFile, endpointsFiles, options); 44 | -------------------------------------------------------------------------------- /functions/api/routes/content/contentCheckPATCH.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { contentDB } = require('../../../db'); 6 | const asyncWrapper = require('../../../lib/asyncWrapper'); 7 | 8 | /** 9 | * @route PATCH /content/check 10 | * @desc 콘텐츠 조회 여부 토글 11 | * @access Private 12 | */ 13 | 14 | module.exports = asyncWrapper(async (req, res) => { 15 | 16 | const { contentId } = req.body 17 | 18 | if (!contentId) { 19 | // 콘텐츠 id가 없을 때 20 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.NULL_VALUE)); 21 | } 22 | 23 | const dbConnection = await db.connect(req); 24 | req.dbConnection = dbConnection; 25 | 26 | const content = await contentDB.toggleContent(dbConnection, contentId); 27 | 28 | if (!content) { 29 | // 특정 콘텐츠 id를 가진 콘텐츠가 존재하지 않을 때 30 | return res.status(statusCode.NOT_FOUND).send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_CONTENT)); 31 | } 32 | 33 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.TOGGLE_CONTENT_SUCCESS, content)); 34 | }); -------------------------------------------------------------------------------- /functions/api/routes/category/categoryPATCH.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { categoryDB } = require('../../../db'); 6 | const asyncWrapper = require('../../../lib/asyncWrapper'); 7 | 8 | /** 9 | * @route PATCH /category/:categoryId 10 | * @desc 카테고리 수정 11 | * @access Private 12 | */ 13 | 14 | module.exports = asyncWrapper(async (req, res) => { 15 | const { categoryId } = req.params; 16 | const { title, imageId } = req.body; 17 | 18 | if (!title || !imageId) { 19 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.NULL_VALUE)); 20 | } 21 | 22 | const dbConnection = await db.connect(req); 23 | req.dbConnection = dbConnection; 24 | 25 | const updatedCategory = await categoryDB.updateCategory(dbConnection, categoryId, title, imageId); 26 | if (!updatedCategory) { 27 | return res.status(statusCode.NOT_FOUND).send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_CATEGORY)); 28 | } 29 | 30 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.UPDATE_ONE_CATEGORY_SUCCESS)); 31 | }); 32 | 33 | -------------------------------------------------------------------------------- /functions/api/routes/category/categoryOrderPATCH.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { categoryDB } = require('../../../db'); 6 | const asyncWrapper = require('../../../lib/asyncWrapper'); 7 | 8 | /** 9 | * @route PATCH /category/order 10 | * @desc 카테고리 순서 변경 11 | * @access Private 12 | */ 13 | 14 | module.exports = asyncWrapper(async (req, res) => { 15 | const { categoryIndexArray } = req.body; 16 | const { userId } = req.user; 17 | 18 | if (!categoryIndexArray) { 19 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.NULL_VALUE)); 20 | } 21 | 22 | let dbConnection = await db.connect(req); 23 | req.dbConnection = dbConnection; 24 | 25 | for (let orderIndex = 0; orderIndex < categoryIndexArray.length; orderIndex++) { // TODO: 비동기 처리 26 | // 카테고리 배열 속 카테고리 id의 순서대로 변경 27 | const contentId = categoryIndexArray[orderIndex]; 28 | await categoryDB.updateCategoryIndex(dbConnection, userId, contentId, orderIndex); 29 | } 30 | 31 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.UPDATE_CATEGORY_ORDER_SUCCESS)); 32 | }); 33 | 34 | -------------------------------------------------------------------------------- /functions/api/routes/category/categoryPOST.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { categoryDB } = require('../../../db'); 6 | const asyncWrapper = require('../../../lib/asyncWrapper'); 7 | 8 | 9 | /** 10 | * @route POST /category 11 | * @desc 카테고리 생성 12 | * @access Private 13 | */ 14 | 15 | module.exports = asyncWrapper(async (req, res) => { 16 | const { title, imageId } = req.body; 17 | let newIndex = 0; 18 | 19 | if (!title || !imageId) { 20 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.NULL_VALUE)); 21 | } 22 | const { userId } = req.user; 23 | 24 | const dbConnection = await db.connect(req); 25 | req.dbConnection = dbConnection; 26 | 27 | const oldCategory = await categoryDB.getAllCategories(dbConnection, userId); 28 | 29 | if(oldCategory.length) { 30 | newIndex = oldCategory[oldCategory.length - 1].orderIndex + 1; // 새 카테고리 인덱스는 마지막 카테고리 인덱스 + 1 31 | } 32 | await categoryDB.addCategory(dbConnection, userId, title, imageId, newIndex); 33 | res.status(statusCode.CREATED).send(util.success(statusCode.CREATED, responseMessage.ADD_ONE_CATEGORY_SUCCESS)); 34 | }); -------------------------------------------------------------------------------- /functions/api/routes/community/communityPostDELETE.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { communityDB } = require('../../../db'); 6 | const asyncWrapper = require('../../../lib/asyncWrapper'); 7 | 8 | /** 9 | * @route DELETE /community/:communityPostId 10 | * @desc 커뮤니티 게시글 삭제 11 | * @access Private 12 | */ 13 | 14 | module.exports = asyncWrapper(async (req, res) => { 15 | const { userId } = req.user; 16 | const { communityPostId } = req.params; 17 | 18 | const dbConnection = await db.connect(req); 19 | req.dbConnection = dbConnection; 20 | 21 | const post = await communityDB.getCommunityPostById(dbConnection, communityPostId); 22 | if (!post) { 23 | return res 24 | .status(statusCode.NOT_FOUND) 25 | .send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_COMMUNITY_POST)); 26 | } 27 | if (post.userId !== userId) { 28 | return res 29 | .status(statusCode.FORBIDDEN) 30 | .send(util.fail(statusCode.FORBIDDEN, responseMessage.FORBIDDEN)); 31 | } 32 | 33 | await communityDB.deleteCommunityPostById(dbConnection, communityPostId); 34 | await communityDB.deleteCommunityCategoryPostByPostId(dbConnection, communityPostId); 35 | 36 | res.status(statusCode.NO_CONTENT).send(); 37 | }); 38 | -------------------------------------------------------------------------------- /functions/api/routes/recommendation/recommendationGET.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const slackAPI = require('../../../middlewares/slackAPI'); 3 | const util = require('../../../lib/util'); 4 | const statusCode = require('../../../constants/statusCode'); 5 | const responseMessage = require('../../../constants/responseMessage'); 6 | const db = require('../../../db/db'); 7 | const { recommendationDB } = require('../../../db'); 8 | 9 | /** 10 | * @route GET /recommendation 11 | * @desc 추천 사이트 전체 조회 12 | * @access Private 13 | */ 14 | 15 | module.exports = async (req, res) => { 16 | 17 | let client; 18 | 19 | try { 20 | client = await db.connect(req); 21 | 22 | const recommendations = await recommendationDB.getAllRecommendations(client); 23 | 24 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.READ_ALL_RECOMMENDATION_SUCCESS, recommendations)); 25 | 26 | } catch (error) { 27 | functions.logger.error(`[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl}`, `[CONTENT] ${error}`); 28 | console.log(error); 29 | const slackMessage = `[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl} ${req.user ? `uid:${req.user.userId}` : 'req.user 없음'} ${JSON.stringify(error)}`; 30 | slackAPI.sendMessageToSlack(slackMessage, slackAPI.WEB_HOOK_ERROR_MONITORING); 31 | 32 | res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, responseMessage.INTERNAL_SERVER_ERROR)); 33 | 34 | } finally { 35 | client.release(); 36 | } 37 | }; -------------------------------------------------------------------------------- /functions/api/routes/user/userNicknamePATCH.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const slackAPI = require('../../../middlewares/slackAPI'); 3 | const util = require('../../../lib/util'); 4 | const statusCode = require('../../../constants/statusCode'); 5 | const responseMessage = require('../../../constants/responseMessage'); 6 | const db = require('../../../db/db'); 7 | const { userDB } = require('../../../db') 8 | 9 | module.exports = async (req, res) => { 10 | 11 | const { userId } = req.user; 12 | const { newNickname } = req.body; 13 | 14 | if (!userId) { 15 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.NULL_VALUE)); 16 | } 17 | 18 | let client; 19 | 20 | try { 21 | client = await db.connect(req); 22 | 23 | await userDB.updateNickname(client, userId, newNickname); 24 | 25 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.UPDATE_USER_NICKNAME_SUCCESS)); 26 | 27 | } catch (error) { 28 | functions.logger.error(`[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl}`, `[CONTENT] ${error}`); 29 | console.log(error); 30 | const slackMessage = `[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl} ${req.user ? `uid:${req.user.userId}` : 'req.user 없음'} ${JSON.stringify(error)}`; 31 | slackAPI.sendMessageToSlack(slackMessage, slackAPI.WEB_HOOK_ERROR_MONITORING); 32 | 33 | res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, responseMessage.INTERNAL_SERVER_ERROR)); 34 | 35 | } finally { 36 | client.release(); 37 | } 38 | }; -------------------------------------------------------------------------------- /functions/api/routes/content/contentUnseenListGET.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { contentDB } = require('../../../db'); 6 | const dayjs = require('dayjs'); 7 | const customParseFormat = require('dayjs/plugin/customParseFormat') 8 | const asyncWrapper = require('../../../lib/asyncWrapper'); 9 | 10 | /** 11 | * @route GET /content/unseen 12 | * @desc 봐야 하는 콘텐츠 조회 13 | * @access Private 14 | */ 15 | 16 | module.exports = asyncWrapper(async (req, res) => { 17 | 18 | const { userId } = req.user; 19 | 20 | const dbConnection = await db.connect(req); 21 | req.dbConnection = dbConnection; 22 | 23 | const contents = await contentDB.getUnseenContents(dbConnection, userId); 24 | 25 | dayjs().format() 26 | dayjs.extend(customParseFormat) 27 | 28 | const result = await Promise.all(contents.map(content => { 29 | // 시간 데이터 dayjs로 format 수정 30 | content.createdAt = dayjs(`${content.createdAt}`).format("YYYY-MM-DD HH:mm"); // createdAt 수정 31 | if (content.notificationTime) { 32 | // notificationTime이 존재할 경우, format 수정 33 | content.notificationTime = dayjs(`${content.notificationTime}`).format("YYYY-MM-DD HH:mm"); 34 | } else { 35 | // notificationTime이 존재하지 않는 경우, null을 빈 문자열로 변경 36 | content.notificationTime = ""; 37 | } 38 | return content; 39 | })); 40 | 41 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.READ_UNSEEN_CONTENT_SUCCESS, result)); 42 | }); -------------------------------------------------------------------------------- /functions/api/routes/community/communityReportPOST.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { communityDB } = require('../../../db'); 6 | const asyncWrapper = require('../../../lib/asyncWrapper'); 7 | const { getCommunityReportMessage } = require('../../../lib/slackMessage'); 8 | const slackAPI = require('../../../middlewares/slackAPI'); 9 | 10 | /** 11 | * @route POST /community/reports 12 | * @desc 커뮤니티 게시글 신고 13 | * @access Private 14 | */ 15 | 16 | module.exports = asyncWrapper(async (req, res) => { 17 | const { userId } = req.user; 18 | const { communityPostId } = req.body; 19 | 20 | const dbConnection = await db.connect(req); 21 | req.dbConnection = dbConnection; 22 | 23 | const communityPostReport = await communityDB.reportCommunityPost( 24 | dbConnection, 25 | userId, 26 | communityPostId, 27 | ); 28 | if (!communityPostReport) { 29 | return res 30 | .status(statusCode.BAD_REQUEST) 31 | .send(util.fail(statusCode.BAD_REQUEST, responseMessage.NO_COMMUNITY_POST)); 32 | } 33 | 34 | const slackReportMessage = getCommunityReportMessage( 35 | userId, 36 | communityPostId, 37 | communityPostReport.title, 38 | communityPostReport.postUserId, 39 | ); 40 | 41 | slackAPI.sendMessageToSlack(slackReportMessage, slackAPI.WEB_HOOK_ERROR_MONITORING, true); 42 | 43 | res 44 | .status(statusCode.CREATED) 45 | .send(util.success(statusCode.CREATED, responseMessage.REPORT_COMMUNITY_POST_SUCCESS)); 46 | }); 47 | -------------------------------------------------------------------------------- /functions/api/routes/category/categoryDELETE.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { categoryDB, categoryContentDB, contentDB } = require('../../../db'); 6 | const asyncWrapper = require('../../../lib/asyncWrapper'); 7 | 8 | /** 9 | * @route DELETE /category/:categoryId 10 | * @desc 카테고리 삭제 11 | * @access Private 12 | */ 13 | 14 | module.exports = asyncWrapper(async (req, res) => { 15 | const { categoryId } = req.params; 16 | const { userId } = req.user; 17 | 18 | const dbConnection = await db.connect(req); 19 | req.dbConnection = dbConnection; 20 | 21 | const category = await categoryDB.getCategory(dbConnection, categoryId); 22 | if (!category) { 23 | return res.status(statusCode.NOT_FOUND).send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_CATEGORY)); 24 | } 25 | if (category.userId !== userId) { 26 | return res.status(statusCode.FORBIDDEN).send(util.fail(statusCode.FORBIDDEN, responseMessage.FORBIDDEN)); 27 | } 28 | 29 | await Promise.all([ 30 | categoryDB.deleteCategory(dbConnection, categoryId, userId), // 해당 카테고리 soft delete 31 | contentDB.updateContentIsDeleted(dbConnection, categoryId, userId), // 카테고리 개수가 1개 (해당 카테고리뿐)인 콘텐츠 soft delete 32 | categoryContentDB.deleteCategoryContentByCategoryId(dbConnection, categoryId) // category_content 테이블 내 해당 카테고리 삭제 33 | ]) 34 | 35 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.DELETE_ONE_CATEGORY_SUCCESS)); 36 | }); -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "description": "Cloud Functions for Firebase", 4 | "scripts": { 5 | "lint": "eslint .", 6 | "serve": "cross-env NODE_ENV=development firebase emulators:start --only functions", 7 | "shell": "firebase functions:shell", 8 | "start": "npm run shell", 9 | "dev": "cross-env NODE_ENV=development firebase deploy --only functions,hosting", 10 | "deploy": "cross-env NODE_ENV=production firebase deploy --only functions,hosting", 11 | "logs": "firebase functions:log", 12 | "test": "mocha", 13 | "swagger": "node config/swagger.js" 14 | }, 15 | "engines": { 16 | "node": "16" 17 | }, 18 | "main": "index.js", 19 | "dependencies": { 20 | "@sentry/node": "^7.52.1", 21 | "axios": "^0.24.0", 22 | "busboy": "^0.3.1", 23 | "cookie-parser": "^1.4.6", 24 | "cors": "^2.8.5", 25 | "dayjs": "^1.10.7", 26 | "dotenv": "^10.0.0", 27 | "eslint-config-prettier": "^8.3.0", 28 | "express": "^4.17.2", 29 | "express-validator": "^7.0.1", 30 | "firebase-admin": "^10.2.0", 31 | "firebase-functions": "^3.14.1", 32 | "helmet": "^5.0.1", 33 | "hpp": "^0.2.3", 34 | "jsonwebtoken": "^8.5.1", 35 | "lodash": "^4.17.21", 36 | "nanoid": "^3.3.4", 37 | "node-forge": "^1.3.1", 38 | "open-graph-scraper": "^4.11.0", 39 | "pg": "^8.7.1", 40 | "request": "^2.88.2", 41 | "request-promise": "^4.2.6", 42 | "swagger-autogen": "^2.23.7", 43 | "swagger-ui-express": "^5.0.0" 44 | }, 45 | "devDependencies": { 46 | "chai": "^4.3.4", 47 | "eslint": "^7.6.0", 48 | "eslint-config-google": "^0.14.0", 49 | "firebase-functions-test": "^0.2.0", 50 | "mocha": "^9.2.1", 51 | "supertest": "^6.2.2" 52 | }, 53 | "private": true 54 | } 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | firebase-debug.*.log* 9 | 10 | # Firebase cache 11 | .firebase/ 12 | 13 | # Firebase config 14 | 15 | # Uncomment this if you'd like others to create their own Firebase project. 16 | # For a team working on the same Firebase project(s), it is recommended to leave 17 | # it commented so all members can deploy to the same project(s) in .firebaserc. 18 | # .firebaserc 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (http://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env.* 67 | 68 | # Firebase service account 69 | havit-wesopt29-firebase-adminsdk-mgljp-478046b091.json 70 | havit-production-firebase-adminsdk-bypl1-d081cc62e4.json 71 | 72 | # Deploy Message To Slack 73 | deployMessageToSlack.sh 74 | 75 | # AWS cert 76 | havit-server-key.cer 77 | 78 | AuthKey_* 79 | 80 | # Swagger 81 | swagger-output.json 82 | 83 | #VSCode 84 | .vscode -------------------------------------------------------------------------------- /functions/api/routes/notice/noticeGET.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const slackAPI = require('../../../middlewares/slackAPI'); 3 | const util = require('../../../lib/util'); 4 | const statusCode = require('../../../constants/statusCode'); 5 | const responseMessage = require('../../../constants/responseMessage'); 6 | const db = require('../../../db/db'); 7 | const { noticeDB } = require('../../../db'); 8 | const dayjs = require('dayjs'); 9 | const customParseFormat = require('dayjs/plugin/customParseFormat') 10 | 11 | /** 12 | * @route GET /notice 13 | * @desc 공지사항 조회 14 | * @access Public 15 | */ 16 | 17 | module.exports = async (req, res) => { 18 | 19 | let client; 20 | 21 | try { 22 | client = await db.connect(req); 23 | 24 | const notices = await noticeDB.getNotices(client); 25 | 26 | dayjs().format() 27 | dayjs.extend(customParseFormat) 28 | 29 | const result = await Promise.all(notices.map(notice => { 30 | notice.createdAt = dayjs(`${notice.createdAt}`).format("YYYY-MM-DD"); 31 | return notice; 32 | })); 33 | 34 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.READ_NOTICES_SUCCESS, result)); 35 | 36 | } catch (error) { 37 | functions.logger.error(`[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl}`, `[CONTENT] ${error}`); 38 | console.log(error); 39 | const slackMessage = `[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl} ${req.user ? `uid:${req.user.userId}` : 'req.user 없음'} ${JSON.stringify(error)}`; 40 | slackAPI.sendMessageToSlack(slackMessage, slackAPI.WEB_HOOK_ERROR_MONITORING); 41 | 42 | res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, responseMessage.INTERNAL_SERVER_ERROR)); 43 | 44 | } finally { 45 | client.release(); 46 | } 47 | }; -------------------------------------------------------------------------------- /functions/api/routes/content/contentSearchGET.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { contentDB } = require('../../../db'); 6 | const dayjs = require('dayjs'); 7 | const customParseFormat = require('dayjs/plugin/customParseFormat'); 8 | const asyncWrapper = require('../../../lib/asyncWrapper'); 9 | 10 | /** 11 | * @route GET /content/search?keyword= 12 | * @desc 전체 콘텐츠 키워드 검색 13 | * @access Private 14 | */ 15 | 16 | module.exports = asyncWrapper(async (req, res) => { 17 | 18 | const { keyword } = req.query; 19 | const { userId } = req.user; 20 | 21 | if (!keyword) { 22 | // 검색 키워드가 없을 때 에러 처리 23 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.NULL_VALUE)); 24 | } 25 | const dbConnection = await db.connect(req); 26 | req.dbConnection = dbConnection; 27 | 28 | const contents = await contentDB.searchContent(dbConnection, userId, keyword); 29 | 30 | dayjs().format() 31 | dayjs.extend(customParseFormat) 32 | 33 | const result = await Promise.all(contents.map(content => { 34 | // 시간 데이터 dayjs로 format 수정 35 | content.createdAt = dayjs(`${content.createdAt}`).format("YYYY-MM-DD HH:mm"); // createdAt 수정 36 | if (content.notificationTime) { 37 | // notificationTime이 존재할 경우, format 수정 38 | content.notificationTime = dayjs(`${content.notificationTime}`).format("YYYY-MM-DD HH:mm"); 39 | } else { 40 | // notificationTime이 존재하지 않는 경우, null을 빈 문자열로 변경 41 | content.notificationTime = ""; 42 | } 43 | return content; 44 | })); 45 | 46 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.KEYWORD_SEARCH_CONTENT_SUCCESS, result)); 47 | }); -------------------------------------------------------------------------------- /functions/api/routes/community/communityPostPOST.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { communityDB } = require('../../../db'); 6 | const asyncWrapper = require('../../../lib/asyncWrapper'); 7 | 8 | /** 9 | * @route POST /community/posts 10 | * @desc 커뮤니티 게시글 작성 11 | * @access Private 12 | */ 13 | 14 | module.exports = asyncWrapper(async (req, res) => { 15 | const { userId } = req.user; 16 | const { 17 | communityCategoryIds, 18 | title, 19 | body, 20 | contentUrl, 21 | contentTitle, 22 | contentDescription, 23 | thumbnailUrl, 24 | } = req.body; 25 | 26 | const dbConnection = await db.connect(req); 27 | req.dbConnection = dbConnection; 28 | 29 | const notExistingCategoryIds = await communityDB.verifyExistCategories( 30 | dbConnection, 31 | communityCategoryIds, 32 | ); 33 | if (notExistingCategoryIds) { 34 | return res 35 | .status(statusCode.BAD_REQUEST) 36 | .send(util.fail(statusCode.BAD_REQUEST, responseMessage.NO_COMMUNITY_CATEGORY)); 37 | } 38 | 39 | const communityPost = await communityDB.addCommunityPost( 40 | dbConnection, 41 | userId, 42 | title, 43 | body, 44 | contentUrl, 45 | contentTitle, 46 | contentDescription, 47 | thumbnailUrl, 48 | ); 49 | 50 | await Promise.all( 51 | communityCategoryIds.map(async (communityCategoryId) => { 52 | await communityDB.addCommunityCategoryPost( 53 | dbConnection, 54 | communityCategoryId, 55 | communityPost.id, 56 | ); 57 | }), 58 | ); 59 | 60 | res 61 | .status(statusCode.CREATED) 62 | .send(util.success(statusCode.CREATED, responseMessage.ADD_COMMUNITY_POST_SUCCESS)); 63 | }); 64 | -------------------------------------------------------------------------------- /functions/lib/pushServerHandlers.js: -------------------------------------------------------------------------------- 1 | const { default: axios } = require('axios'); 2 | 3 | const baseURL = process.env.PUSH_SERVER_URL; 4 | 5 | const createPushServerUser = async (fcmToken) => { 6 | const url = `${baseURL}user`; 7 | 8 | const response = await axios.post(url, { fcmToken }); 9 | 10 | return response.data.data._id; 11 | } 12 | 13 | const createNotification = async (data) => { 14 | const url = `${baseURL}reminder`; 15 | 16 | const response = await axios.post(url, data); 17 | 18 | return response.data; 19 | } 20 | 21 | const modifyNotificationTime = async (contentId, time) => { 22 | const url = `${baseURL}reminder`; 23 | 24 | const response = await axios.patch(url, { 25 | contentId, 26 | time 27 | }); 28 | 29 | return response.data; 30 | } 31 | 32 | const modifyFcmToken = async (mongoUserId, fcmToken) => { 33 | const url = `${baseURL}user/${mongoUserId}/refresh-token`; 34 | 35 | const response = await axios.put(url, { 36 | fcmToken 37 | }); 38 | 39 | return response; 40 | } 41 | 42 | const deleteNotification = async (contentId) => { 43 | const url = `${baseURL}reminder/${contentId}`; 44 | 45 | const response = await axios.delete(url); 46 | 47 | return response 48 | } 49 | 50 | const modifyContentTitle = async (contentId, ogTitle) => { 51 | const url = `${baseURL}reminder/title`; 52 | 53 | const response = await axios.patch(url, { 54 | contentId, 55 | ogTitle 56 | }); 57 | 58 | return response; 59 | } 60 | 61 | const deletePushUser = async (mongoUserId) => { 62 | const url = `${baseURL}user/${mongoUserId}`; 63 | 64 | const response = await axios.delete(url); 65 | 66 | return response; 67 | } 68 | 69 | module.exports = { createPushServerUser, createNotification, modifyNotificationTime, modifyFcmToken, deleteNotification, modifyContentTitle, deletePushUser } -------------------------------------------------------------------------------- /functions/lib/jwtHandlers.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const jwt = require('jsonwebtoken'); 3 | const { TOKEN_INVALID, TOKEN_EXPIRED } = require('../constants/jwt'); 4 | 5 | // JWT를 발급/인증할 때 사용할 secretKey, options 설정 6 | const secretKey = process.env.JWT_SECRET; 7 | const accessTokenOptions = { 8 | expiresIn: process.env.JWT_ACCESS_EXPIRE, 9 | issuer: 'havit', 10 | }; 11 | const refreshTokenOptions = { 12 | expiresIn: process.env.JWT_REFRESH_EXPIRE, 13 | issuer: 'havit', 14 | }; 15 | 16 | // id, idFirebase가 담긴 JWT를 발급 17 | const sign = (user) => { 18 | const payload = { 19 | userId: user.id, 20 | idFirebase: user.idFirebase, 21 | }; 22 | const accessToken = jwt.sign(payload, secretKey, accessTokenOptions); 23 | return accessToken; 24 | }; 25 | 26 | // Refresh Token 발급 (payload가 없음) 27 | const signRefresh = () => { 28 | const refreshToken = jwt.sign({}, secretKey, refreshTokenOptions); 29 | return refreshToken; 30 | } 31 | 32 | // JWT를 해독해 우리가 만든 JWT가 맞는지 확인 (인증) 33 | const verify = (jwtToken) => { 34 | let decoded; 35 | try { 36 | decoded = jwt.verify(jwtToken, secretKey); 37 | } catch (err) { 38 | if (err.message === 'jwt expired') { 39 | console.log('expired token'); 40 | functions.logger.error('expired token'); 41 | return TOKEN_EXPIRED; 42 | } else if (err.message === 'invalid token') { 43 | console.log('invalid token'); 44 | functions.logger.error('invalid token'); 45 | return TOKEN_INVALID; 46 | } else { 47 | console.log('invalid token'); 48 | functions.logger.error('invalid token'); 49 | return TOKEN_INVALID; 50 | } 51 | } 52 | // 해독 / 인증이 완료된 JWT 반환 53 | return decoded; 54 | }; 55 | 56 | module.exports = { 57 | sign, 58 | verify, 59 | signRefresh, 60 | }; -------------------------------------------------------------------------------- /functions/api/routes/content/contentCategoryGET.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const util = require('../../../lib/util'); 3 | const statusCode = require('../../../constants/statusCode'); 4 | const responseMessage = require('../../../constants/responseMessage'); 5 | const db = require('../../../db/db'); 6 | const { contentDB, categoryDB, categoryContentDB } = require('../../../db'); 7 | const asyncWrapper = require('../../../lib/asyncWrapper'); 8 | 9 | /** 10 | * @route GET /content/category/:contentId 11 | * @desc 콘텐츠 소속 카테고리 조회 12 | * @access Private 13 | */ 14 | 15 | module.exports = asyncWrapper(async (req, res) => { 16 | const { contentId } = req.params; 17 | const { userId } = req.user; 18 | const dbConnection = await db.connect(req); 19 | req.dbConnection = dbConnection; 20 | 21 | // 콘텐츠가 없거나, 해당 유저의 콘텐츠가 아닌 경우 제한 22 | const content = await contentDB.getContentById(dbConnection, contentId); 23 | if (!content) { 24 | return res.status(statusCode.NOT_FOUND).send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_CONTENT)); 25 | } 26 | if (content.userId !== userId) { 27 | return res.status(statusCode.FORBIDDEN).send(util.fail(statusCode.FORBIDDEN, responseMessage.FORBIDDEN)); 28 | } 29 | 30 | const [allCategories, contentCategories] = await Promise.all([ 31 | categoryDB.getAllCategories(dbConnection, userId), 32 | categoryContentDB.getCategoryContentByContentId(dbConnection, contentId, userId) 33 | ]); 34 | 35 | const data = await Promise.all(allCategories.map(category => { 36 | category.isSelected = false; 37 | 38 | const result = contentCategories.some(cc => cc.id === category.id); 39 | if (result) category.isSelected = true; 40 | 41 | return category; 42 | })); 43 | 44 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.READ_CONTENT_CATEGORY_SUCCESS, data)); 45 | }); -------------------------------------------------------------------------------- /functions/api/routes/content/contentNotificationGET.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const util = require('../../../lib/util'); 3 | const statusCode = require('../../../constants/statusCode'); 4 | const responseMessage = require('../../../constants/responseMessage'); 5 | const db = require('../../../db/db'); 6 | const { contentDB } = require('../../../db'); 7 | const dayjs = require('dayjs'); 8 | const customParseFormat = require('dayjs/plugin/customParseFormat') 9 | const asyncWrapper = require('../../../lib/asyncWrapper'); 10 | 11 | /** 12 | * @route GET /content/notification?option= 13 | * @desc 콘텐츠 알람 전체 조회 14 | * @access Private 15 | */ 16 | 17 | module.exports = asyncWrapper(async (req, res) => { 18 | const { userId } = req.user; 19 | const { option } = req.query; 20 | 21 | const dbConnection = await db.connect(req); 22 | req.dbConnection = dbConnection; 23 | 24 | dayjs().format() 25 | dayjs.extend(customParseFormat) 26 | 27 | let contents; 28 | 29 | if (option === 'before') { 30 | contents = await contentDB.getScheduledContentNotification(dbConnection, userId); 31 | } else if(option === 'after') { 32 | contents = await contentDB.getExpiredContentNotification(dbConnection, userId); 33 | } else { 34 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.OUT_OF_VALUE)); 35 | } 36 | 37 | const contentList = await Promise.all(contents.map(content => { 38 | if (content.notificationTime) { 39 | content.notificationTime = dayjs(`${content.notificationTime}`).format('YYYY-MM-DD HH:mm'); 40 | } else { 41 | content.notificationTime = ''; 42 | } 43 | 44 | content.createdAt = dayjs(`${content.createdAt}`).format('YYYY-MM-DD'); 45 | return content; 46 | })); 47 | 48 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.READ_CONTENT_NOTIFICATION_SUCCESS, contentList)); 49 | }); -------------------------------------------------------------------------------- /functions/api/routes/user/userGET.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const slackAPI = require('../../../middlewares/slackAPI'); 3 | const util = require('../../../lib/util'); 4 | const statusCode = require('../../../constants/statusCode'); 5 | const responseMessage = require('../../../constants/responseMessage'); 6 | const db = require('../../../db/db'); 7 | const { userDB, contentDB, categoryDB } = require('../../../db'); 8 | 9 | module.exports = async (req, res) => { 10 | 11 | const { userId } = req.user; 12 | 13 | let client; 14 | 15 | try { 16 | client = await db.connect(req); 17 | 18 | const user = await userDB.getUser(client, userId); 19 | const contents = await contentDB.getContentsByFilter(client, userId, 'created_at'); 20 | const categories = await categoryDB.getAllCategories(client, userId); 21 | const unSeenContents = await contentDB.getUnseenContents(client, userId); 22 | 23 | const result = { 24 | nickname : user.nickname, 25 | email: user.email, 26 | totalContentNumber : contents.length, 27 | totalCategoryNumber : categories.length, 28 | totalSeenContentNumber : contents.length - unSeenContents.length 29 | }; 30 | 31 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.READ_ONE_USER_SUCCESS, result)); 32 | 33 | } catch (error) { 34 | functions.logger.error(`[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl}`, `[CONTENT] ${error}`); 35 | console.log(error); 36 | const slackMessage = `[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl} ${req.user ? `uid:${req.user.userId}` : 'req.user 없음'} ${JSON.stringify(error)}`; 37 | slackAPI.sendMessageToSlack(slackMessage, slackAPI.WEB_HOOK_ERROR_MONITORING); 38 | 39 | res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, responseMessage.INTERNAL_SERVER_ERROR)); 40 | 41 | } finally { 42 | client.release(); 43 | } 44 | }; -------------------------------------------------------------------------------- /functions/api/routes/category/categoryContentSearchGET.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { categoryContentDB } = require('../../../db'); 6 | const dayjs = require('dayjs'); 7 | const customParseFormat = require('dayjs/plugin/customParseFormat'); 8 | const asyncWrapper = require('../../../lib/asyncWrapper'); 9 | 10 | /** 11 | * @route GET /category/search?categoryId=&keyword= 12 | * @desc 카테고리 별 콘텐츠 키워드 검색 13 | * @access Private 14 | */ 15 | 16 | module.exports = asyncWrapper(async (req, res) => { 17 | 18 | const { categoryId } = req.params; 19 | const { keyword } = req.query; 20 | const { userId } = req.user; 21 | 22 | if (!categoryId || !keyword) { 23 | // 카테고리나 검색 키워드가 없을 때 에러 처리 24 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.NULL_VALUE)); 25 | } 26 | let dbConnection = await db.connect(req); 27 | req.dbConnection = dbConnection; 28 | 29 | const contents = await categoryContentDB.searchCategoryContent(dbConnection, userId, categoryId, keyword); 30 | 31 | dayjs().format(); 32 | dayjs.extend(customParseFormat); 33 | 34 | const result = await Promise.all(contents.map(content => { 35 | // 시간 데이터 dayjs로 format 수정 36 | content.createdAt = dayjs(`${content.createdAt}`).format("YYYY-MM-DD HH:mm"); // createdAt 수정 37 | if (content.notificationTime) { 38 | // notificationTime이 존재할 경우, format 수정 39 | content.notificationTime = dayjs(`${content.notificationTime}`).format("YYYY-MM-DD HH:mm"); 40 | } else { 41 | // notificationTime이 존재하지 않는 경우, null을 빈 문자열로 변경 42 | content.notificationTime = ""; 43 | } 44 | return content; 45 | })); 46 | 47 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.KEYWORD_SEARCH_CATEGORY_CONTENT_SUCCESS, result)); 48 | }); -------------------------------------------------------------------------------- /functions/api/routes/content/contentRenamePATCH.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { contentDB } = require('../../../db'); 6 | const { modifyContentTitle } = require('../../../lib/pushServerHandlers'); 7 | const dayjs = require('dayjs'); 8 | const timezone = require('dayjs/plugin/timezone'); 9 | const utc = require('dayjs/plugin/utc'); 10 | const asyncWrapper = require('../../../lib/asyncWrapper'); 11 | 12 | /** 13 | * @route PATCH /content/title/:contentId 14 | * @desc 콘텐츠 제목 변경 15 | * @access Private 16 | */ 17 | 18 | module.exports = asyncWrapper(async (req, res) => { 19 | 20 | const { contentId } = req.params; 21 | const { newTitle } = req.body; 22 | 23 | if (!contentId || !newTitle) { 24 | // 필수 데이터가 없는 경우 에러 처리 25 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.NULL_VALUE)); 26 | } 27 | 28 | const dbConnection = await db.connect(req); 29 | req.dbConnection = dbConnection; 30 | 31 | dayjs().format(); 32 | dayjs.extend(utc); 33 | dayjs.extend(timezone); 34 | 35 | dayjs.tz.setDefault('Asia/Seoul'); 36 | 37 | const content = await contentDB.renameContent(dbConnection, contentId, newTitle); 38 | 39 | if (!content) { 40 | // 대상 콘텐츠가 없는 경우, 콘텐츠 제목 변경 실패 41 | return res.status(statusCode.NOT_FOUND).send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_CONTENT)); 42 | } 43 | 44 | if (content.isNotified && content.notificationTime > dayjs().tz().$d) { 45 | const response = await modifyContentTitle(contentId, newTitle); 46 | if (response.status !== 204) { 47 | return res.status(res.status).send(util.fail(response.status, responseMessage.PUSH_SERVER_ERROR)); 48 | } 49 | } 50 | 51 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.RENAME_CONTENT_SUCCESS)); 52 | }); -------------------------------------------------------------------------------- /functions/middlewares/validator/communityValidator.js: -------------------------------------------------------------------------------- 1 | const { body, query, param } = require('express-validator'); 2 | 3 | const createCommunityPostValidator = [ 4 | body('communityCategoryIds') 5 | .isArray() 6 | .notEmpty() 7 | .withMessage('Invalid communityCategoryIds field'), 8 | body('title').isString().notEmpty().withMessage('Invalid title field'), 9 | body('body').isString().notEmpty().withMessage('Invalid body field'), 10 | body('contentUrl').isString().notEmpty().withMessage('Invalid contentUrl field'), 11 | body('contentTitle').isString().notEmpty().withMessage('Invalid contentTitle field'), 12 | ]; 13 | 14 | const getCommunityPostsValidator = [ 15 | query('page').notEmpty().isInt({ min: 1 }).withMessage('Invalid page field'), 16 | query('limit').notEmpty().isInt({ min: 1 }).withMessage('Invalid limit field'), 17 | ]; 18 | 19 | const getCommunityPostValidator = [ 20 | param('communityPostId') 21 | .notEmpty() 22 | .isInt({ min: 1 }) 23 | .withMessage('Invalid communityPostId field'), 24 | ]; 25 | 26 | const getCommunityCategoryPostsValidator = [ 27 | query('page').notEmpty().isInt({ min: 1 }).withMessage('Invalid page field'), 28 | query('limit').notEmpty().isInt({ min: 1 }).withMessage('Invalid limit field'), 29 | param('communityCategoryId') 30 | .notEmpty() 31 | .isInt({ min: 1 }) 32 | .withMessage('Invalid communityCategoryId field'), 33 | ]; 34 | 35 | const reportCommunityPostValidator = [ 36 | body('communityPostId').notEmpty().isInt({ min: 1 }).withMessage('Invalid communityPostId field'), 37 | ]; 38 | 39 | const deleteCommunityPostValidator = [ 40 | param('communityPostId') 41 | .notEmpty() 42 | .isInt({ min: 1 }) 43 | .withMessage('Invalid communityPostId field'), 44 | ]; 45 | 46 | module.exports = { 47 | createCommunityPostValidator, 48 | getCommunityPostsValidator, 49 | getCommunityPostValidator, 50 | getCommunityCategoryPostsValidator, 51 | reportCommunityPostValidator, 52 | deleteCommunityPostValidator, 53 | }; 54 | -------------------------------------------------------------------------------- /functions/api/routes/content/contentRecentListGET.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { contentDB, categoryContentDB } = require('../../../db'); 6 | const dayjs = require('dayjs'); 7 | const customParseFormat = require('dayjs/plugin/customParseFormat'); 8 | const asyncWrapper = require('../../../lib/asyncWrapper'); 9 | 10 | /** 11 | * @route GET /content/recent 12 | * @desc 최근 저장 콘텐츠 조회 13 | * @access Private 14 | */ 15 | 16 | module.exports = asyncWrapper(async (req, res) => { 17 | 18 | const { userId } = req.user; 19 | 20 | const dbConnection = await db.connect(req); 21 | req.dbConnection = dbConnection; 22 | 23 | const contents = await contentDB.getRecentContents(dbConnection, userId); // 최대 20개까지 조회 24 | await Promise.all( // 각 콘텐츠가 소속된 카테고리 병렬 탐색 25 | contents.map(async (content) => { 26 | let categories = await categoryContentDB.getCategoryContentByContentId(dbConnection, content.id, userId); 27 | content.firstCategory = categories[0]?.title; 28 | content.extraCategoryCount = categories.length - 1; 29 | return; 30 | }) 31 | ); 32 | 33 | dayjs().format() 34 | dayjs.extend(customParseFormat) 35 | 36 | const result = await Promise.all(contents.map(content => { 37 | // 시간 데이터 dayjs로 format 수정 38 | content.createdAt = dayjs(`${content.createdAt}`).format("YYYY-MM-DD HH:mm"); // createdAt 수정 39 | if (content.notificationTime) { 40 | // notificationTime이 존재할 경우, format 수정 41 | content.notificationTime = dayjs(`${content.notificationTime}`).format("YYYY-MM-DD HH:mm"); 42 | } else { 43 | // notificationTime이 존재하지 않는 경우, null을 빈 문자열로 변경 44 | content.notificationTime = ""; 45 | } 46 | return content; 47 | })); 48 | 49 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.READ_RECENT_SAVED_CONTENT_SUCCESS, result)); 50 | }); -------------------------------------------------------------------------------- /functions/api/routes/community/communityPostGET.js: -------------------------------------------------------------------------------- 1 | const dayjs = require('dayjs'); 2 | const customParseFormat = require('dayjs/plugin/customParseFormat'); 3 | const util = require('../../../lib/util'); 4 | const statusCode = require('../../../constants/statusCode'); 5 | const responseMessage = require('../../../constants/responseMessage'); 6 | const dummyImages = require('../../../constants/dummyImages'); 7 | const db = require('../../../db/db'); 8 | const { communityDB } = require('../../../db'); 9 | const asyncWrapper = require('../../../lib/asyncWrapper'); 10 | 11 | /** 12 | * @route GET /community/posts/:communityPostId 13 | * @desc 커뮤니티 게시글 상세 조회 14 | * @access Private 15 | */ 16 | 17 | module.exports = asyncWrapper(async (req, res) => { 18 | const { userId } = req.user; 19 | const { communityPostId } = req.params; 20 | 21 | const dbConnection = await db.connect(req); 22 | req.dbConnection = dbConnection; 23 | 24 | const isReportedPost = await communityDB.getReportedPostByUser( 25 | dbConnection, 26 | userId, 27 | communityPostId, 28 | ); 29 | if (isReportedPost) { 30 | return res 31 | .status(statusCode.BAD_REQUEST) 32 | .send(util.fail(statusCode.BAD_REQUEST, responseMessage.ALREADY_REPORTED_POST)); 33 | } 34 | 35 | dayjs().format(); 36 | dayjs.extend(customParseFormat); 37 | 38 | const communityPost = await communityDB.getCommunityPostDetail( 39 | dbConnection, 40 | communityPostId, 41 | userId, 42 | ); 43 | if (!communityPost) { 44 | return res 45 | .status(statusCode.NOT_FOUND) 46 | .send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_COMMUNITY_POST)); 47 | } 48 | 49 | communityPost.createdAt = dayjs(`${communityPost.createdAt}`).format('YYYY. MM. DD'); 50 | communityPost.thumbnailUrl = communityPost.thumbnailUrl || dummyImages.content_dummy; 51 | 52 | res.status(statusCode.OK).send( 53 | util.success(statusCode.OK, responseMessage.READ_COMMUNITY_POST_SUCCESS, { 54 | ...communityPost, 55 | profileImage: dummyImages.user_profile_dummy, 56 | }), 57 | ); 58 | }); 59 | -------------------------------------------------------------------------------- /functions/api/routes/content/contentCategoryPATCH.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { categoryDB, categoryContentDB } = require('../../../db'); 6 | const asyncWrapper = require('../../../lib/asyncWrapper'); 7 | 8 | /** 9 | * @route PATCH /content/category 10 | * @desc 콘텐츠 카테고리 변경 11 | * @access Private 12 | */ 13 | 14 | module.exports = asyncWrapper(async (req, res) => { 15 | const { contentId, newCategoryIds } = req.body; 16 | const { userId } = req.user; 17 | 18 | if (!newCategoryIds.length) { 19 | // 변경 할 카테고리 id가 없을 때 20 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.NULL_VALUE)); 21 | } 22 | 23 | const dbConnection = await db.connect(req); 24 | req.dbConnection = dbConnection; 25 | 26 | let flag = true; // flag 변수 결과에 따라 categoryContent를 추가할 지, 에러를 보낼 지 결정 27 | for (const newCategoryId of newCategoryIds) { 28 | // 카테고리 배열의 id 중 하나라도 유저의 카테고리가 아닐 경우, 에러 전송 29 | const newCategory = await categoryDB.getCategory(dbConnection, newCategoryId); 30 | if (!newCategory || newCategory.userId !== userId) { 31 | // 카테고리가 아예 존재하지 않거나, 해당 유저의 카테고리가 아닌 경우 32 | flag = false; 33 | } 34 | } 35 | 36 | if (flag) { 37 | // 유저가 해당 카테고리를 가지고 있을 때 38 | const promises = []; 39 | promises.push(categoryContentDB.deleteCategoryContentByContentId(dbConnection, contentId)); // 기존 카테고리-콘텐츠 관계 모두 삭제 40 | for (const newCategoryId of newCategoryIds) { 41 | // 새로운 카테고리마다 새로운 카테고리-콘텐츠 관계 생성 42 | promises.push(categoryContentDB.addCategoryContent(dbConnection, newCategoryId, contentId)); 43 | } 44 | await Promise.all(promises) 45 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.UPDATE_CONTENT_CATEGORY_SUCCESS)); 46 | } else { 47 | // 카테고리를 변경할 수 없는 경우, 에러 전송 48 | res.status(statusCode.NOT_FOUND).send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_CATEGORY)); 49 | } 50 | }); -------------------------------------------------------------------------------- /functions/api/routes/auth/userDELETE.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { userDB } = require("../../../db"); 6 | const { getAuth } = require('firebase-admin/auth'); 7 | const { nanoid } = require("nanoid"); 8 | const { deletePushUser } = require('../../../lib/pushServerHandlers'); 9 | const { revokeAppleToken } = require('../../../lib/appleAuth'); 10 | const asyncWrapper = require('../../../lib/asyncWrapper'); 11 | 12 | 13 | /** 14 | * @route DELETE /auth/user 15 | * @desc 회원 탈퇴 16 | * @access Public 17 | */ 18 | 19 | module.exports = asyncWrapper(async (req, res) => { 20 | 21 | const { userId } = req.user; 22 | const dbConnection = await db.connect(req); 23 | let deleteUser = await userDB.getUser(dbConnection, userId); // DB에서 해당 유저 정보 받아 옴 24 | if (deleteUser.appleRefreshToken) { 25 | await revokeAppleToken(deleteUser.appleRefreshToken); // 애플 유저라면 애플 계정 연동 해지 26 | } 27 | 28 | try { 29 | // 500 INTERNAL SERVER ERROR가 아닌 경우에만 error에 부가 정보 삽입 30 | await getAuth().deleteUser(deleteUser.idFirebase); // Firebase Auth에서 해당 유저 삭제 31 | } catch (error) { 32 | if (error.errorInfo.code === 'auth/user-not-found') { 33 | // Firebase Auth에 해당 유저 존재하지 않을 경우 : 404 NOT FOUND 34 | error.statusCode = statusCode.NOT_FOUND; 35 | error.responseMessage = responseMessage.NO_USER; 36 | } 37 | throw error; 38 | } 39 | 40 | const randomString = `:${nanoid(10)}`; 41 | await userDB.deleteUser(dbConnection, userId, randomString); // DB에서 해당 유저 삭제 42 | 43 | const response = await deletePushUser(deleteUser.mongoUserId); 44 | if (response.status !== 204) { 45 | // 푸시 서버 에러 발생 시 푸시 서버 에러임을 명시 46 | const pushServerError = new Error(); 47 | pushServerError.statusCode = response.statusCode; 48 | pushServerError.responseMessage = response.statusText; 49 | throw pushServerError; 50 | } 51 | 52 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.DELETE_USER)); 53 | }); -------------------------------------------------------------------------------- /functions/api/routes/user/userUpdateFcmTokenPUT.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const slackAPI = require('../../../middlewares/slackAPI'); 3 | const util = require('../../../lib/util'); 4 | const statusCode = require('../../../constants/statusCode'); 5 | const responseMessage = require('../../../constants/responseMessage'); 6 | const db = require('../../../db/db'); 7 | const { userDB } = require('../../../db'); 8 | const { modifyFcmToken } = require('../../../lib/pushServerHandlers'); 9 | 10 | module.exports = async (req, res) => { 11 | const { fcmToken } = req.body; 12 | const { userId } = req.user; 13 | 14 | if (!fcmToken) { 15 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.NULL_VALUE)); 16 | } 17 | 18 | let client; 19 | 20 | try { 21 | client = await db.connect(req); 22 | 23 | const user = await userDB.getUser(client, userId); 24 | 25 | if (!user.mongoUserId) { 26 | return res.status(statusCode.NOT_FOUND).send(util.fail(statusCode.NOT_FOUND, responseMessage.NOT_FOUND)); 27 | } 28 | 29 | const response = await modifyFcmToken(user.mongoUserId, fcmToken); 30 | 31 | if (response.status !== 204) { 32 | return res.status(response.statusCode).send(util.fail(response.statusCode, response.statusText)); 33 | } 34 | 35 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.UPDATE_FCM_TOKEN_SUCCESS)); 36 | 37 | } catch (error) { 38 | functions.logger.error(`[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl}`, `[CONTENT] ${error}`); 39 | console.log(error); 40 | const slackMessage = `[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl} ${req.user ? `uid:${req.user.userId}` : 'req.user 없음'} ${JSON.stringify(error)}`; 41 | slackAPI.sendMessageToSlack(slackMessage, slackAPI.WEB_HOOK_ERROR_MONITORING); 42 | 43 | res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, responseMessage.INTERNAL_SERVER_ERROR)); 44 | 45 | } finally { 46 | client.release(); 47 | } 48 | }; -------------------------------------------------------------------------------- /functions/api/routes/auth/reissueTokenPOST.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { userDB } = require('../../../db'); 6 | const jwtHandlers = require('../../../lib/jwtHandlers'); 7 | const { TOKEN_INVALID, TOKEN_EXPIRED } = require('../../../constants/jwt'); 8 | const asyncWrapper = require('../../../lib/asyncWrapper'); 9 | 10 | /** 11 | * @route POST /auth/token 12 | * @desc 토큰 재발급 13 | * @access Public 14 | */ 15 | 16 | module.exports = asyncWrapper(async (req, res) => { 17 | const { userId } = req.body; 18 | const refreshToken = req.header("refresh-token"); 19 | 20 | const decodedToken = jwtHandlers.verify(refreshToken); 21 | if (decodedToken === TOKEN_EXPIRED) { 22 | // 토큰 만료 23 | return res.status(statusCode.UNAUTHORIZED).send(util.fail(statusCode.UNAUTHORIZED, responseMessage.TOKEN_EXPIRED)); 24 | } 25 | if (decodedToken === TOKEN_INVALID) { 26 | // 유효하지 않은 토큰 27 | return res.status(statusCode.UNAUTHORIZED).send(util.fail(statusCode.UNAUTHORIZED, responseMessage.TOKEN_INVALID)); 28 | } 29 | // 토큰 만료, 유효하지 않은 토큰 모두 강제 로그아웃 필요함 30 | 31 | const dbConnection = await db.connect(req); 32 | req.dbConnection = dbConnection; 33 | const user = await userDB.getUser(dbConnection, userId); 34 | 35 | // DB user 테이블의 Refresh Token과 클라이언트에게 받아 온 Refresh Token 비교 36 | if (refreshToken === user.refreshToken) { 37 | // Refresh Token 일치 : Aceess Token, Refresh Token 재발급, DB 업데이트 38 | const newAccessToken = jwtHandlers.sign(user); 39 | const newRefreshToken = jwtHandlers.signRefresh(); 40 | await userDB.updateRefreshToken(dbConnection, user.id, newRefreshToken); 41 | const reissuedTokens = { 42 | 'accessToken' : newAccessToken, 43 | 'refreshToken' : newRefreshToken 44 | }; 45 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.TOKEN_REISSUE_SUCCESS, reissuedTokens)); 46 | } 47 | else { 48 | // Refresh Token 불일치 : 강제 로그아웃 49 | return res.status(statusCode.UNAUTHORIZED).send(util.fail(statusCode.UNAUTHORIZED, responseMessage.TOKEN_INVALID)); 50 | } 51 | }); -------------------------------------------------------------------------------- /functions/api/routes/content/contentDELETE.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { contentDB, categoryContentDB } = require('../../../db'); 6 | const { deleteNotification } = require('../../../lib/pushServerHandlers'); 7 | const dayjs = require('dayjs'); 8 | const timezone = require('dayjs/plugin/timezone'); 9 | const utc = require('dayjs/plugin/utc'); 10 | const asyncWrapper = require('../../../lib/asyncWrapper'); 11 | 12 | /** 13 | * @route DELETE /content/:contentId 14 | * @desc 콘텐츠 삭제 15 | * @access Private 16 | */ 17 | 18 | module.exports = asyncWrapper(async (req, res) => { 19 | 20 | const { contentId } = req.params; 21 | const { userId } = req.user; 22 | 23 | if (!contentId) { 24 | // 삭제할 콘텐츠 id가 없을 때 25 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.NULL_VALUE)); 26 | } 27 | 28 | const dbConnection = await db.connect(req); 29 | req.dbConnection = dbConnection; 30 | 31 | dayjs().format(); 32 | dayjs.extend(utc); 33 | dayjs.extend(timezone); 34 | 35 | dayjs.tz.setDefault('Asia/Seoul'); 36 | 37 | const content = await contentDB.getContentById(dbConnection, contentId); 38 | if (!content) { 39 | return res.status(statusCode.NOT_FOUND).send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_CONTENT)); 40 | } 41 | if (content.userId !== userId) { 42 | return res.status(statusCode.FORBIDDEN).send(util.fail(statusCode.FORBIDDEN, responseMessage.FORBIDDEN)); 43 | } 44 | 45 | await contentDB.deleteContent(dbConnection, contentId, userId); 46 | 47 | if (content.isNotified && content.notificationTime > dayjs().tz().$d) { 48 | // 알림 예정 일 때 푸시서버에서도 삭제 49 | const response = await deleteNotification(contentId); 50 | 51 | if (response.status !== 200) { 52 | return res.status(res.status).send(util.fail(response.status, responseMessage.PUSH_SERVER_ERROR)); 53 | } 54 | } 55 | 56 | await categoryContentDB.deleteCategoryContentByContentId(dbConnection, contentId); 57 | 58 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.DELETE_CONTENT_SUCCESS)); 59 | }); -------------------------------------------------------------------------------- /functions/api/routes/content/contentNotificationDELETE.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const util = require('../../../lib/util'); 3 | const statusCode = require('../../../constants/statusCode'); 4 | const responseMessage = require('../../../constants/responseMessage'); 5 | const db = require('../../../db/db'); 6 | const { contentDB } = require('../../../db'); 7 | const { deleteNotification } = require('../../../lib/pushServerHandlers'); 8 | const dayjs = require('dayjs'); 9 | const timezone = require('dayjs/plugin/timezone'); 10 | const utc = require('dayjs/plugin/utc'); 11 | const asyncWrapper = require('../../../lib/asyncWrapper'); 12 | 13 | 14 | /** 15 | * @route DELETE /content/:contentId/notification 16 | * @desc 콘텐츠 알람 삭제 17 | * @access Private 18 | */ 19 | 20 | module.exports = asyncWrapper(async (req, res) => { 21 | const { userId } = req.user; 22 | const { contentId } = req.params; 23 | 24 | const dbConnection = await db.connect(req); 25 | req.dbConnection = dbConnection; 26 | 27 | dayjs().format(); 28 | dayjs.extend(utc); 29 | dayjs.extend(timezone); 30 | 31 | dayjs.tz.setDefault('Asia/Seoul'); 32 | 33 | const content = await contentDB.getContentById(dbConnection, contentId); 34 | if (!content) { 35 | return res.status(statusCode.NOT_FOUND).send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_CONTENT)); 36 | } 37 | if (content.userId !== userId) { 38 | return res.status(statusCode.FORBIDDEN).send(util.fail(statusCode.FORBIDDEN, responseMessage.FORBIDDEN)); 39 | } 40 | if (!content.isNotified) { 41 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.OUT_OF_VALUE)); 42 | } 43 | 44 | if (content.notificationTime > dayjs().tz().$d) { 45 | // 알림 예정 일 때 푸시서버에서도 삭제 46 | const response = await deleteNotification(contentId); 47 | 48 | if (response.status !== 200) { 49 | return res.status(res.status).send(util.fail(response.status, responseMessage.PUSH_SERVER_ERROR)); 50 | } 51 | } 52 | 53 | await contentDB.updateContentNotification(dbConnection, contentId, null, false); 54 | 55 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.DELETE_CONTENT_NOTIFICATION_SUCCESS)); 56 | }); -------------------------------------------------------------------------------- /functions/api/routes/community/communityPostsGET.js: -------------------------------------------------------------------------------- 1 | const dayjs = require('dayjs'); 2 | const customParseFormat = require('dayjs/plugin/customParseFormat'); 3 | const util = require('../../../lib/util'); 4 | const statusCode = require('../../../constants/statusCode'); 5 | const responseMessage = require('../../../constants/responseMessage'); 6 | const asyncWrapper = require('../../../lib/asyncWrapper'); 7 | const db = require('../../../db/db'); 8 | const { communityDB } = require('../../../db'); 9 | const dummyImages = require('../../../constants/dummyImages'); 10 | 11 | /** 12 | * @route GET /community/posts 13 | * @desc 커뮤니티 게시글 전체 조회 14 | * @access Private 15 | */ 16 | 17 | module.exports = asyncWrapper(async (req, res) => { 18 | const { userId } = req.user; 19 | const { page, limit } = req.query; 20 | 21 | const dbConnection = await db.connect(req); 22 | req.dbConnection = dbConnection; 23 | 24 | dayjs().format(); 25 | dayjs.extend(customParseFormat); 26 | 27 | const totalItemCount = await communityDB.getCommunityPostsCount(dbConnection, userId); // 총 게시글 수 28 | const totalPageCount = Math.ceil(totalItemCount / limit); 29 | const currentPage = +page; 30 | 31 | // 게시글이 없는 경우 32 | if (totalItemCount === 0) { 33 | return res 34 | .status(statusCode.OK) 35 | .send(util.success(statusCode.OK, responseMessage.NO_COMMUNITY_POST)); 36 | } 37 | 38 | // 요청한 페이지가 존재하지 않는 경우 39 | if (page > totalPageCount) { 40 | return res 41 | .status(statusCode.NOT_FOUND) 42 | .send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_PAGE)); 43 | } 44 | 45 | const offset = (page - 1) * limit; 46 | const communityPosts = await communityDB.getCommunityPosts(dbConnection, userId, limit, offset); 47 | 48 | // 각 게시글의 createdAt 형식 변경, 프로필 이미지 추가, 썸네일 이미지 null일 경우 대체 이미지 추가 49 | const result = await Promise.all( 50 | communityPosts.map((communityPost) => { 51 | communityPost.createdAt = dayjs(`${communityPost.createdAt}`).format('YYYY. MM. DD'); 52 | communityPost.profileImage = dummyImages.user_profile_dummy; 53 | communityPost.thumbnailUrl = communityPost.thumbnailUrl || dummyImages.content_dummy; 54 | return communityPost; 55 | }), 56 | ); 57 | 58 | res.status(statusCode.OK).send( 59 | util.success(statusCode.OK, responseMessage.READ_COMMUNITY_POSTS_SUCCESS, { 60 | posts: result, 61 | currentPage, 62 | totalPageCount, 63 | totalItemCount, 64 | isLastPage: currentPage === totalPageCount, 65 | }), 66 | ); 67 | }); 68 | -------------------------------------------------------------------------------- /functions/api/routes/content/contentListGET.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const util = require('../../../lib/util'); 3 | const statusCode = require('../../../constants/statusCode'); 4 | const responseMessage = require('../../../constants/responseMessage'); 5 | const db = require('../../../db/db'); 6 | const { contentDB } = require('../../../db'); 7 | const dayjs = require('dayjs'); 8 | const customParseFormat = require('dayjs/plugin/customParseFormat'); 9 | const asyncWrapper = require('../../../lib/asyncWrapper'); 10 | 11 | /** 12 | * @route GET /content 13 | * @desc 콘텐츠 전체 조회 14 | * @access Private 15 | */ 16 | 17 | module.exports = asyncWrapper(async (req, res) => { 18 | const { userId } = req.user; 19 | const { option, filter } = req.query; 20 | 21 | const dbConnection = await db.connect(req); 22 | req.dbConnection = dbConnection; 23 | 24 | let contents; 25 | 26 | if (option === "all") { 27 | // 전체 조회 28 | contents = await contentDB.getContentsByFilter(dbConnection, userId, filter); 29 | } else if (option === "notified") { 30 | // 알림 설정된 콘텐츠만 조회 31 | contents = await contentDB.getContentsByFilterAndNotified(dbConnection, userId, true, filter) 32 | } else { 33 | // is_seen에 따라 조회 34 | contents = await contentDB.getContentsByFilterAndSeen(dbConnection, userId, option, filter); 35 | } 36 | 37 | if (filter === "reverse") { 38 | // DESC를 이용했으므로 다시 reverse 39 | contents = contents.reverse(); 40 | } 41 | if (filter === "seen_at") { 42 | // 최근 조회 순 기준인 경우, 조회하지 않은 콘텐츠 제외 43 | const removedElements = _.remove(contents, function(content) { 44 | return content.isSeen === false; 45 | }); 46 | } 47 | 48 | dayjs().format() 49 | dayjs.extend(customParseFormat) 50 | 51 | const result = await Promise.all(contents.map(content => { 52 | // 시간 데이터 dayjs로 format 수정 53 | content.createdAt = dayjs(`${content.createdAt}`).format("YYYY-MM-DD HH:mm"); // createdAt 수정 54 | if (content.notificationTime) { 55 | // notificationTime이 존재할 경우, format 수정 56 | content.notificationTime = dayjs(`${content.notificationTime}`).format("YYYY-MM-DD HH:mm"); 57 | } else { 58 | // notificationTime이 존재하지 않는 경우, null을 빈 문자열로 변경 59 | content.notificationTime = ""; 60 | } 61 | if (content.seenAt) { 62 | content.seenAt = dayjs(`${content.seenAt}`).format("YYYY-MM-DD HH:mm"); 63 | } else { 64 | content.seenAt = ""; 65 | } 66 | return content; 67 | })); 68 | 69 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.READ_ALL_CONTENT_SUCCESS , result)); 70 | }); -------------------------------------------------------------------------------- /functions/api/routes/category/categoryContentGET.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const util = require('../../../lib/util'); 3 | const statusCode = require('../../../constants/statusCode'); 4 | const responseMessage = require('../../../constants/responseMessage'); 5 | const db = require('../../../db/db'); 6 | const { categoryContentDB } = require('../../../db'); 7 | const dayjs = require('dayjs'); 8 | const customParseFormat = require('dayjs/plugin/customParseFormat'); 9 | const asyncWrapper = require('../../../lib/asyncWrapper'); 10 | 11 | /** 12 | * @route GET /category/:categoryId 13 | * @desc 카테고리 별 콘텐츠 조회 14 | * @access Private 15 | */ 16 | 17 | module.exports = asyncWrapper(async (req, res) => { 18 | const { userId } = req.user; 19 | const { categoryId } = req.params; 20 | const { option, filter } = req.query; 21 | let contents = {}; 22 | let dbConnection = await db.connect(req); 23 | req.dbConnection = dbConnection; 24 | 25 | dayjs().format(); 26 | dayjs.extend(customParseFormat); 27 | 28 | if (option === "all") { 29 | // 전체 조회 30 | contents = await categoryContentDB.getAllCategoryContentByFilter(dbConnection, userId, categoryId, filter); 31 | } else if (option === "notified") { 32 | // 알림 설정된 콘텐츠만 조회 33 | contents = await categoryContentDB.getCategoryContentByFilterAndNotified(dbConnection, userId, categoryId, true, filter) 34 | 35 | } else { 36 | // is_seen에 따라 조회 37 | contents = await categoryContentDB.getCategoryContentByFilterAndSeen(dbConnection, userId, categoryId, option, filter); 38 | } 39 | if (filter === "reverse") { 40 | // DESC를 이용했으므로 다시 reverse 41 | contents = contents.reverse(); 42 | } 43 | if (filter === "seen_at") { 44 | // 최근 조회 순 기준인 경우, 조회하지 않은 콘텐츠 제외 45 | _.remove(contents, function(content) { 46 | return content.isSeen === false; 47 | }); 48 | } 49 | 50 | const result = await Promise.all(contents.map(content => { 51 | /** 52 | * 클라이언트가 사용할 createdAt, notificationTime, seenAt 은 day js로 format 수정 53 | * 시간이 null이면 빈 문자열로 수정 54 | */ 55 | content.createdAt = dayjs(`${content.createdAt}`).format("YYYY-MM-DD HH:mm"); 56 | if (content.notificationTime) { 57 | content.notificationTime = dayjs(`${content.notificationTime}`).format("YYYY-MM-DD HH:mm"); 58 | } else { 59 | content.notificationTime = ""; 60 | } 61 | if (content.seenAt) { 62 | content.seenAt = dayjs(`${content.seenAt}`).format("YYYY-MM-DD HH:mm"); 63 | } else { 64 | content.seenAt = ""; 65 | } 66 | return content; 67 | })); 68 | 69 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.READ_CATEGORY_CONTENT_SUCCESS, result)); 70 | }); -------------------------------------------------------------------------------- /functions/api/routes/content/contentNotificationPATCH.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const statusCode = require('../../../constants/statusCode'); 3 | const responseMessage = require('../../../constants/responseMessage'); 4 | const db = require('../../../db/db'); 5 | const { contentDB, userDB } = require('../../../db'); 6 | const { modifyNotificationTime, createNotification } = require('../../../lib/pushServerHandlers'); 7 | const dayjs = require('dayjs'); 8 | const timezone = require('dayjs/plugin/timezone'); 9 | const utc = require('dayjs/plugin/utc'); 10 | const asyncWrapper = require('../../../lib/asyncWrapper'); 11 | 12 | /** 13 | * @route PATCH /content/:contentId/notification 14 | * @desc 콘텐츠 알림 시각 수정 15 | * @access Private 16 | */ 17 | 18 | module.exports = asyncWrapper(async (req, res) => { 19 | const { contentId } = req.params; 20 | const { notificationTime } = req.body; 21 | const { userId } = req.user; 22 | 23 | if (!notificationTime) { 24 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.NULL_VALUE)); 25 | } 26 | 27 | const dbConnection = await db.connect(req); 28 | req.dbConnection = dbConnection; 29 | 30 | let response; 31 | 32 | dayjs().format(); 33 | dayjs.extend(utc); 34 | dayjs.extend(timezone); 35 | 36 | dayjs.tz.setDefault('Asia/Seoul'); 37 | 38 | const content = await contentDB.getContentById(dbConnection, contentId); 39 | if (!content) { 40 | return res.status(statusCode.NOT_FOUND).send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_CONTENT)); 41 | } 42 | if (content.userId !== userId) { 43 | return res.status(statusCode.FORBIDDEN).send(util.fail(statusCode.FORBIDDEN, responseMessage.FORBIDDEN)); 44 | } 45 | 46 | const user = await userDB.getUser(dbConnection, userId); 47 | 48 | if (content.notificationTime && content.notificationTime > dayjs().tz().$d) { 49 | response = await modifyNotificationTime(contentId, notificationTime); 50 | } else { 51 | const data = { 52 | userId: user.mongoUserId, 53 | time: notificationTime, 54 | ogTitle: content.title, 55 | ogImage: content.image, 56 | url: content.url, 57 | isSeen: content.isSeen, 58 | contentId 59 | }; 60 | response = await createNotification(data); 61 | } 62 | if (response.status !== 200 && response.status !== 201) { 63 | return res.status(response.status).send(util.fail(response.status, responseMessage.PUSH_SERVER_ERROR)); 64 | } 65 | 66 | await contentDB.updateContentNotification(dbConnection, contentId, notificationTime, true); 67 | 68 | res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.UPDATE_CONTENT_NOTIFICATION_SUCCESS)); 69 | }); 70 | 71 | -------------------------------------------------------------------------------- /functions/lib/appleAuth.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const jwt = require('jsonwebtoken'); 3 | const functions = require("firebase-functions"); 4 | const slackAPI = require("../middlewares/slackAPI"); 5 | const fs = require('fs').promises; 6 | const { resolve } = require('path'); 7 | const qs = require('qs'); 8 | 9 | const appleBaseURL = "https://appleid.apple.com"; 10 | 11 | const getPrivateKey = async () => { 12 | return (await fs.readFile(resolve(__dirname, `../${process.env.APPLE_PRIVATE_KEY_FILE}`), 'utf8')).toString(); 13 | } 14 | 15 | const header = { 16 | kid: process.env.APPLE_KEY_ID, 17 | }; 18 | 19 | const payload = { 20 | iss: process.env.APPLE_TEAM_ID, 21 | iat: Math.floor(Date.now() / 1000), 22 | exp: Math.floor(Date.now() / 1000) + 15777000, 23 | aud: appleBaseURL, 24 | sub: process.env.APPLE_CLIENT_ID, 25 | }; 26 | 27 | /** 28 | * @desc apple 서버에 refresh token 요청 29 | * @param {String} appleCode 30 | */ 31 | const getAppleRefreshToken = async (appleCode) => { 32 | const clientSecret = jwt.sign(payload, await getPrivateKey(), { 33 | algorithm: 'ES256', 34 | header 35 | }); 36 | 37 | const data = { 38 | 'client_id': process.env.APPLE_CLIENT_ID, 39 | 'client_secret': clientSecret, 40 | 'code': appleCode, 41 | 'grant_type': 'authorization_code' 42 | }; 43 | 44 | try { 45 | const response = await axios.post(`${appleBaseURL}/auth/token`, qs.stringify(data)); 46 | 47 | return response.data.refresh_token; 48 | } catch (error) { 49 | functions.logger.error(`[ERROR] Get Apple Access Token` , `[CONTENT] ${error}`); 50 | const slackMessage = `[ERROR] Get Apple Access Token ${JSON.stringify(error)}`; 51 | slackAPI.sendMessageToSlack(slackMessage, slackAPI.WEB_HOOK_ERROR_MONITORING); 52 | throw error; 53 | } 54 | } 55 | 56 | /** 57 | * @desc apple 서버에 소셜 로그인 연결 해지 요청 58 | * @param {String} appleRefreshToken 59 | */ 60 | const revokeAppleToken = async (appleRefreshToken) => { 61 | const clientSecret = jwt.sign(payload, await getPrivateKey(), { 62 | algorithm: 'ES256', 63 | header 64 | }); 65 | 66 | const data = { 67 | 'client_id': process.env.APPLE_CLIENT_ID, 68 | 'client_secret': clientSecret, 69 | 'token': appleRefreshToken, 70 | 'token_type_hint': 'refresh_token', 71 | }; 72 | 73 | try { 74 | await axios.post(`${appleBaseURL}/auth/revoke`, qs.stringify(data)); 75 | } catch (error) { 76 | functions.logger.error(`[ERROR] Get Apple Access Token`, `[CONTENT] ${error}`); 77 | const slackMessage = `[ERROR] Get Apple Access Token ${JSON.stringify(error)}`; 78 | slackAPI.sendMessageToSlack(slackMessage, slackAPI.WEB_HOOK_ERROR_MONITORING); 79 | throw error; 80 | } 81 | } 82 | 83 | module.exports = { getAppleRefreshToken, revokeAppleToken } -------------------------------------------------------------------------------- /functions/api/index.js: -------------------------------------------------------------------------------- 1 | // 각종 모듈들 2 | const functions = require("firebase-functions"); 3 | const express = require("express"); 4 | const cors = require("cors"); 5 | const cookieParser = require("cookie-parser"); 6 | const hpp = require("hpp"); 7 | const helmet = require("helmet"); 8 | const Sentry = require('@sentry/node'); 9 | const errorHandler = require('../middlewares/errorHandler'); 10 | const swaggerUi = require("swagger-ui-express"); 11 | const swaggerFile = require("../constants/swagger/swagger-output.json"); 12 | 13 | // initializing 14 | const app = express(); 15 | 16 | Sentry.init({ 17 | dsn: process.env.SENTRY_DSN, 18 | environment: `${process.env.NODE_ENV}_app`, 19 | integrations: [ 20 | new Sentry.Integrations.Http({ tracing: true }), 21 | new Sentry.Integrations.Express({ app }), 22 | ...Sentry.autoDiscoverNodePerformanceMonitoringIntegrations(), 23 | ], 24 | tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE, 25 | }); 26 | 27 | app.use(Sentry.Handlers.requestHandler()); 28 | app.use(Sentry.Handlers.tracingHandler()); 29 | 30 | 31 | // Cross-Origin Resource Sharing을 열어주는 미들웨어 32 | // https://even-moon.github.io/2020/05/21/about-cors/ 에서 자세한 정보 확인 33 | app.use(cors()); 34 | 35 | // 보안을 위한 미들웨어들 36 | // process.env.NODE_ENV는 배포된 서버에서는 'production'으로, 로컬에서 돌아가는 서버에서는 'development'로 고정됨. 37 | if (process.env.NODE_ENV === "production") { 38 | app.use(hpp()); 39 | app.use(helmet()); 40 | } 41 | 42 | // request에 담긴 정보를 json 형태로 파싱하기 위한 미들웨어들 43 | app.use(express.json()); 44 | app.use(express.urlencoded({ extended: true })); 45 | app.use(cookieParser()); 46 | 47 | if (process.env.NODE_ENV === "development") { 48 | app.use("/swagger", swaggerUi.serve); 49 | app.get("/swagger", swaggerUi.setup(swaggerFile)); 50 | } 51 | 52 | // 라우팅: routes 폴더로 정리 53 | app.use("/", require("./routes")); 54 | app.use(Sentry.Handlers.errorHandler()); 55 | app.use(errorHandler); 56 | 57 | // route 폴더에 우리가 지정할 경로가 아닌 다른 경로로 요청이 올 경우 58 | // 잘못된 경로로 요청이 들어왔다는 메세지를 클라이언트에 보냄 59 | app.use("*", (req, res) => { 60 | res.status(404).json({ 61 | status: 404, 62 | success: false, 63 | message: "잘못된 경로입니다.", 64 | }); 65 | }); 66 | 67 | // express를 firebase functions로 감싸주는 코드 68 | module.exports = functions 69 | .runWith({ 70 | timeoutSeconds: 300, // 요청을 처리하는 과정이 300초를 초과하면 타임아웃 시키기 71 | memory: "512MB", // 서버에 할당되는 메모리 72 | }) 73 | .region("asia-northeast3") // 서버가 돌아갈 region. asia-northeast3는 서울 74 | .https.onRequest(async (req, res) => { 75 | 76 | // 들어오는 요청에 대한 로그를 콘솔에 찍기. 디버깅 때 유용하게 쓰일 예정. 77 | // 콘솔에 찍고 싶은 내용을 원하는 대로 추가하면 됨. (req.headers, req.query 등) 78 | console.log("\n\n", "[api]", `[${req.method.toUpperCase()}]`, req.originalUrl, req.body); 79 | 80 | // 맨 위에 선언된 express app 객체를 리턴 81 | // 요것이 functions/index.js 안의 api: require("./api")에 들어가는 것. 82 | return app(req, res); 83 | }); -------------------------------------------------------------------------------- /functions/constants/responseMessage.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | NULL_VALUE: '필요한 값이 없습니다', 3 | OUT_OF_VALUE: '파라미터 값이 잘못되었습니다', 4 | FORBIDDEN: '허용되지 않은 접근입니다.', 5 | 6 | // 인증 7 | SIGNIN_SUCCESS: '소셜 로그인 성공', 8 | SIGNUP_SUCCESS: '회원 가입 성공', 9 | DELETE_USER: '회원 탈퇴 성공', 10 | ALREADY_EMAIL: '이미 사용중인 이메일입니다.', 11 | NO_USER: '존재하지 않는 회원입니다.', 12 | INVALID_EMAIL: '잘못된 이메일입니다.', 13 | TOKEN_EXPIRED: '토큰이 만료되었습니다.', 14 | TOKEN_INVALID: '토큰이 유효하지 않습니다.', 15 | TOKEN_EMPTY: '토큰이 없습니다.', 16 | TOKEN_REISSUE_SUCCESS: '토큰 재발급 성공', 17 | 18 | // 유저 19 | READ_ONE_USER_SUCCESS: '유저 조회 성공', 20 | UPDATE_USER_NICKNAME_SUCCESS: '유저 수정 성공', 21 | READ_PROFILE_SUCCESS: '프로필 조회 성공', 22 | 23 | // 콘텐츠 24 | ADD_ONE_CONTENT_SUCCESS: '콘텐츠 생성 성공', 25 | TOGGLE_CONTENT_SUCCESS: '콘텐츠 조회 여부 토글 성공', 26 | READ_ALL_CONTENT_SUCCESS: '전체 콘텐츠 조회 성공', 27 | KEYWORD_SEARCH_CONTENT_SUCCESS: '전체 콘텐츠 키워드 검색 성공', 28 | KEYWORD_SEARCH_CATEGORY_CONTENT_SUCCESS: '카테고리 별 콘텐츠 키워드 검색 성공', 29 | READ_RECENT_SAVED_CONTENT_SUCCESS: '최근 저장 콘텐츠 조회 성공', 30 | READ_UNSEEN_CONTENT_SUCCESS: '봐야 하는 콘텐츠 조회 성공', 31 | DELETE_CONTENT_SUCCESS: '콘텐츠 삭제 성공', 32 | NO_CONTENT: '존재하지 않는 콘텐츠', 33 | RENAME_CONTENT_SUCCESS: '콘텐츠 제목 변경 성공', 34 | UPDATE_CONTENT_CATEGORY_SUCCESS: '콘텐츠 카테고리 변경 성공', 35 | UPDATE_CONTENT_NOTIFICATION_SUCCESS: '콘텐츠 알림 변경 성공', 36 | DELETE_CONTENT_NOTIFICATION_SUCCESS: '콘텐츠 알림 삭제 성공', 37 | DUPLICATED_CONTENT: '중복된 콘텐츠', 38 | READ_CONTENT_NOTIFICATION_SUCCESS: '콘텐츠 알림 조회 성공', 39 | 40 | // 카테고리 41 | ADD_ONE_CATEGORY_SUCCESS: '카테고리 생성 완료', 42 | READ_CATEGORY_SUCCESS: '카테고리 전체 조회 성공', 43 | READ_CATEGORY_NAME_SUCCESS: '카테고리 이름 조회 성공', 44 | READ_CATEGORY_CONTENT_SUCCESS: '카테고리 별 콘텐츠 조회 성공', 45 | UPDATE_ONE_CATEGORY_SUCCESS: '카테고리 수정 성공', 46 | DELETE_ONE_CATEGORY_SUCCESS: '카테고리 삭제 성공', 47 | NO_CATEGORY: '존재하지 않는 카테고리', 48 | UPDATE_CATEGORY_ORDER_SUCCESS: '카테고리 순서 변경 성공', 49 | DUPLICATED_CATEGORY: '중복된 카테고리', 50 | READ_CONTENT_CATEGORY_SUCCESS: '콘텐츠 소속 카테고리 조회 성공', 51 | 52 | // 추천 사이트 53 | READ_ALL_RECOMMENDATION_SUCCESS: '추천 사이트 조회 성공', 54 | 55 | // 서버 내 오류 56 | INTERNAL_SERVER_ERROR: '서버 내 오류', 57 | 58 | // 푸시 서버 59 | UPDATE_FCM_TOKEN_SUCCESS: 'fcm 토큰 수정 성공', 60 | PUSH_SERVER_ERROR: '푸시 서버 내 오류', 61 | 62 | // 공지사항 63 | READ_NOTICES_SUCCESS: '공지사항 조회 성공', 64 | 65 | // 커뮤니티 66 | READ_COMMUNITY_POST_SUCCESS: '커뮤니티 게시글 상세 조회 성공', 67 | READ_COMMUNITY_POSTS_SUCCESS: '커뮤니티 게시글 전체 조회 성공', 68 | NO_COMMUNITY_POST: '존재하지 않는 커뮤니티 게시글', 69 | ADD_COMMUNITY_POST_SUCCESS: '커뮤니티 게시글 작성 성공', 70 | NO_COMMUNITY_CATEGORY: '존재하지 않는 커뮤니티 카테고리', 71 | NO_PAGE: '존재하지 않는 페이지', 72 | READ_COMMUNITY_CATEGORIES_SUCCESS: '커뮤니티 카테고리 조회 성공', 73 | ALREADY_REPORTED_POST: '이미 신고한 게시글', 74 | READ_COMMUNITY_CATEGORY_POSTS_SUCCESS: '커뮤니티 카테고리별 게시글 조회 성공', 75 | REPORT_COMMUNITY_POST_SUCCESS: '커뮤니티 게시글 신고 성공', 76 | DELETE_COMMUNITY_POST_SUCCESS: '커뮤니티 게시글 삭제 성공', 77 | 78 | // 서버 상태 체크 79 | HEALTH_CHECK_SUCCESS: '서버 상태 정상', 80 | }; 81 | -------------------------------------------------------------------------------- /functions/api/routes/community/communityCategoryPostsGET.js: -------------------------------------------------------------------------------- 1 | const dayjs = require('dayjs'); 2 | const customParseFormat = require('dayjs/plugin/customParseFormat'); 3 | const util = require('../../../lib/util'); 4 | const statusCode = require('../../../constants/statusCode'); 5 | const responseMessage = require('../../../constants/responseMessage'); 6 | const asyncWrapper = require('../../../lib/asyncWrapper'); 7 | const db = require('../../../db/db'); 8 | const { communityDB } = require('../../../db'); 9 | const dummyImages = require('../../../constants/dummyImages'); 10 | /** 11 | * @route GET /community/categories/:communityCategoryId 12 | * @desc 커뮤니티 카테고리별 게시글 조회 13 | * @access Private 14 | */ 15 | 16 | module.exports = asyncWrapper(async (req, res) => { 17 | const { userId } = req.user; 18 | const { page, limit } = req.query; 19 | const { communityCategoryId } = req.params; 20 | 21 | const dbConnection = await db.connect(req); 22 | req.dbConnection = dbConnection; 23 | 24 | dayjs().format(); 25 | dayjs.extend(customParseFormat); 26 | 27 | // category id가 존재하지 않는 경우 28 | const isExistingCategory = await communityDB.isExistingCategory( 29 | dbConnection, 30 | communityCategoryId, 31 | ); 32 | if (!isExistingCategory) { 33 | return res 34 | .status(statusCode.NOT_FOUND) 35 | .send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_CATEGORY)); 36 | } 37 | 38 | const totalItemCount = await communityDB.getCommunityCategoryPostsCount( 39 | dbConnection, 40 | userId, 41 | communityCategoryId, 42 | ); 43 | const totalPageCount = Math.ceil(totalItemCount / limit); 44 | const currentPage = +page; 45 | 46 | // 게시글이 없는 경우 47 | if (totalItemCount === 0) { 48 | return res 49 | .status(statusCode.OK) 50 | .send(util.success(statusCode.OK, responseMessage.NO_COMMUNITY_POST)); 51 | } 52 | 53 | // 요청한 페이지가 존재하지 않는 경우 54 | if (page > totalPageCount) { 55 | return res 56 | .status(statusCode.NOT_FOUND) 57 | .send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_PAGE)); 58 | } 59 | 60 | const offset = (page - 1) * limit; 61 | const communityCategoryPosts = await communityDB.getCommunityCategoryPostsById( 62 | dbConnection, 63 | userId, 64 | communityCategoryId, 65 | limit, 66 | offset, 67 | ); 68 | // 각 게시글의 createdAt 형식 변경 및 프로필 이미지 추가 69 | const result = await Promise.all( 70 | communityCategoryPosts.map((communityPost) => { 71 | communityPost.createdAt = dayjs(`${communityPost.createdAt}`).format('YYYY. MM. DD'); 72 | communityPost.profileImage = dummyImages.user_profile_dummy; 73 | return communityPost; 74 | }), 75 | ); 76 | 77 | res.status(statusCode.OK).send( 78 | util.success(statusCode.OK, responseMessage.READ_COMMUNITY_CATEGORY_POSTS_SUCCESS, { 79 | posts: result, 80 | currentPage, 81 | totalPageCount, 82 | totalItemCount, 83 | isLastPage: currentPage === totalPageCount, 84 | }), 85 | ); 86 | }); 87 | -------------------------------------------------------------------------------- /functions/db/db.js: -------------------------------------------------------------------------------- 1 | // 필요한 모듈들 2 | const functions = require('firebase-functions'); 3 | const { Pool, Query } = require('pg'); 4 | const dayjs = require('dayjs'); 5 | 6 | // DB Config (유저, 호스트, DB 이름, 패스워드)를 로딩해줍시다. 7 | const dbConfig = require('../config/dbConfig'); 8 | 9 | // NODE_ENV라는 글로벌 환경변수를 사용해서, 현재 환경이 어떤 '모드'인지 판별해줍시다. 10 | let devMode = process.env.NODE_ENV === 'development'; 11 | 12 | // SQL 쿼리문을 콘솔에 프린트할지 말지 결정해주는 변수를 선언합시다. 13 | const sqlDebug = true; 14 | 15 | // 기본 설정에서는 우리가 실행하게 되는 SQL 쿼리문이 콘솔에 찍히지 않기 때문에, 16 | // pg 라이브러리 내부의 함수를 살짝 손봐서 SQL 쿼리문이 콘솔에 찍히게 만들어 줍시다. 17 | const submit = Query.prototype.submit; 18 | Query.prototype.submit = function () { 19 | const text = this.text; 20 | const values = this.values || []; 21 | const query = text.replace(/\$([0-9]+)/g, (m, v) => JSON.stringify(values[parseInt(v) - 1])); 22 | // devMode === true 이면서 sqlDebug === true일 때 SQL 쿼리문을 콘솔에 찍겠다는 분기입니다. 23 | devMode && sqlDebug && console.log(`\n\n[👻 SQL STATEMENT]\n${query}\n_________\n`); 24 | submit.apply(this, arguments); 25 | }; 26 | 27 | // 서버가 실행되면 현재 환경이 개발 모드(로컬)인지 프로덕션 모드(배포)인지 콘솔에 찍어줍시다. 28 | console.log(`[🔥DB] ${process.env.NODE_ENV}`); 29 | 30 | // 커넥션 풀을 생성해줍니다. 31 | const pool = new Pool({ 32 | ...dbConfig, 33 | connectionTimeoutMillis: 10 * 1000, 34 | idleTimeoutMillis: 10 * 1000, 35 | }); 36 | 37 | // 위에서 생성한 커넥션 풀에서 커넥션을 빌려오는 함수를 정의합니다. 38 | // 기본적으로 제공되는 pool.connect()와 pool.connect().release() 함수에 디버깅용 메시지를 추가하는 작업입니다. 39 | const connect = async (req) => { 40 | const now = dayjs(); 41 | const string = 42 | !!req && !!req.method 43 | ? `[${req.method}] ${!!req.user ? `${req.user.id}` : ``} ${req.originalUrl}\n ${!!req.query && `query: ${JSON.stringify(req.query)}`} ${!!req.body && `body: ${JSON.stringify(req.body)}`} ${ 44 | !!req.params && `params ${JSON.stringify(req.params)}` 45 | }` 46 | : `request 없음`; 47 | const callStack = new Error().stack; 48 | const client = await pool.connect(); 49 | const query = client.query; 50 | const release = client.release; 51 | 52 | const releaseChecker = setTimeout(() => { 53 | devMode 54 | ? console.error('[ERROR] client connection이 15초 동안 릴리즈되지 않았습니다.', { callStack }) 55 | : functions.logger.error('[ERROR] client connection이 15초 동안 릴리즈되지 않았습니다.', { callStack }); 56 | devMode ? console.error(`마지막으로 실행된 쿼리문입니다. ${client.lastQuery}`) : functions.logger.error(`마지막으로 실행된 쿼리문입니다. ${client.lastQuery}`); 57 | }, 15 * 1000); 58 | 59 | client.query = (...args) => { 60 | client.lastQuery = args; 61 | return query.apply(client, args); 62 | }; 63 | client.release = () => { 64 | clearTimeout(releaseChecker); 65 | const time = dayjs().diff(now, 'millisecond'); 66 | if (time > 4000) { 67 | const message = `[RELEASE] in ${time} | ${string}`; 68 | devMode && console.log(message); 69 | } 70 | client.query = query; 71 | client.release = release; 72 | return release.apply(client); 73 | }; 74 | return client; 75 | }; 76 | 77 | module.exports = { 78 | connect, 79 | }; 80 | -------------------------------------------------------------------------------- /functions/middlewares/auth.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const jwtHandlers = require('../lib/jwtHandlers'); 3 | const db = require('../db/db'); 4 | const util = require('../lib/util'); 5 | const statusCode = require('../constants/statusCode'); 6 | const responseMessage = require('../constants/responseMessage'); 7 | const slackAPI = require('./slackAPI'); 8 | const { userDB } = require('../db'); 9 | const { TOKEN_INVALID, TOKEN_EXPIRED } = require('../constants/jwt'); 10 | 11 | const checkUser = async (req, res, next) => { 12 | // request headers로 전송받은 accessToken 13 | const accessToken = req.header("x-auth-token"); 14 | 15 | // accessToken이 없을 때 16 | if (!accessToken) { 17 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.TOKEN_EMPTY)); 18 | } 19 | 20 | let client; 21 | try { 22 | client = await db.connect(req); 23 | 24 | // accessToken 인증 및 해독 25 | const decodedToken = jwtHandlers.verify(accessToken); 26 | 27 | if (decodedToken === TOKEN_EXPIRED) { 28 | // 토큰 만료 29 | return res.status(statusCode.UNAUTHORIZED).send(util.fail(statusCode.UNAUTHORIZED, responseMessage.TOKEN_EXPIRED)); 30 | } 31 | if (decodedToken === TOKEN_INVALID) { 32 | // 유효하지 않은 토큰 33 | return res.status(statusCode.UNAUTHORIZED).send(util.fail(statusCode.UNAUTHORIZED, responseMessage.TOKEN_INVALID)); 34 | } 35 | 36 | // 토큰에 담긴 유저 id 37 | const userId = decodedToken.userId; 38 | if (!userId) { 39 | // 토큰에 유저 id가 존재하지 않을 때 (유효하지 않은 토큰) 40 | return res.status(statusCode.UNAUTHORIZED).send(util.fail(statusCode.UNAUTHORIZED, responseMessage.TOKEN_INVALID)); 41 | } 42 | 43 | // 유저 DB에서 해당 유저의 정보 조회 44 | const user = await userDB.getUser(client, userId); 45 | 46 | if (!user || user.isDeleted) { 47 | // 해당 유저가 존재하지 않을 때 48 | return res.status(statusCode.UNAUTHORIZED).send(util.fail(statusCode.UNAUTHORIZED, responseMessage.NO_USER)); 49 | } 50 | 51 | // 유저 DB로부터 받아 온 유저 정보를 req.user에 담아 다음 미들웨어로 전달 52 | const returnUser = { 53 | userId : user.id, 54 | firebaseId : user.idFirebase, 55 | } 56 | req.user = returnUser; 57 | next(); 58 | } catch (error) { 59 | console.log(error); 60 | functions.logger.error(`[AUTH ERROR] [${req.method.toUpperCase()}] ${req.originalUrl}`, accessToken); 61 | const slackMessage = `[ERROR] [${req.method.toUpperCase()}] ${req.originalUrl} ${req.user ? `uid:${req.user.userId}` : 'req.user 없음'} ${JSON.stringify(error)}`; 62 | slackAPI.sendMessageToSlack(slackMessage, slackAPI.WEB_HOOK_ERROR_MONITORING); 63 | 64 | res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, responseMessage.INTERNAL_SERVER_ERROR)); 65 | } finally { 66 | client.release(); 67 | } 68 | }; 69 | 70 | module.exports = { checkUser }; -------------------------------------------------------------------------------- /functions/lib/kakaoAuth.js: -------------------------------------------------------------------------------- 1 | const util = require('./util'); 2 | const statusCode = require('../constants/statusCode'); 3 | const responseMessage = require('../constants/responseMessage'); 4 | const request = require('request-promise'); 5 | const firebaseAdmin = require('firebase-admin'); 6 | const { res } = require('express'); 7 | const requestMeUrl = 'https://kapi.kakao.com/v2/user/me?secure_resource=true'; 8 | 9 | /** 10 | * @desc Kakao 서버에 유저 프로필 정보 요청 11 | * @param {String} KakaoAccessToken 12 | */ 13 | const requestMe = async (kakaoAccessToken) => { 14 | return request({ 15 | method: 'GET', 16 | headers: {'Authorization': 'Bearer ' + kakaoAccessToken}, 17 | url: requestMeUrl, 18 | }); 19 | }; 20 | 21 | /** 22 | * @desc Firebase Auth에 유저 업데이트. 존재하지 않는 유저일 경우 유저 생성 23 | * @param {String} userId 24 | * @param {String} email 25 | * @param {String} displayName 26 | * @param {String} photoURL 27 | */ 28 | const updateOrCreateUser = async (userId, email, displayName, photoURL) => { 29 | const updateParams = { 30 | provider: 'KAKAO', 31 | email, 32 | }; 33 | if (displayName) { 34 | updateParams['displayName'] = displayName; 35 | } else { 36 | updateParams['displayName'] = email; 37 | } 38 | if (photoURL) { 39 | updateParams['photoURL'] = photoURL; 40 | } 41 | 42 | return firebaseAdmin.auth().updateUser(userId, updateParams) // 유저 존재하는 경우, 업데이트 43 | .catch((error) => { 44 | if (error.code === 'auth/user-not-found') { 45 | updateParams['uid'] = userId; 46 | return firebaseAdmin.auth().createUser(updateParams); // 유저 존재하지 않는 경우, 생성 47 | } 48 | throw error; 49 | }); 50 | }; 51 | 52 | /** 53 | * @desc Firebase Token 생성 54 | * @param {String} KakaoAccessToken 55 | */ 56 | const createFirebaseToken = async (kakaoAccessToken) => { 57 | const kakaoUser = await requestMe(kakaoAccessToken); // Kakao 유저 정보 58 | const kakaoUserData = JSON.parse(kakaoUser); 59 | const KakaoUserId = `kakao:${kakaoUserData.id}`; // Kakao 고유 ID 60 | if (!KakaoUserId) { 61 | // 해당 Kakao Access Token에 해당하는 Kakao 유저가 존재하지 않을 때 62 | return res.status(statusCode.NOT_FOUND).send(util.fail(statusCode.NOT_FOUND, responseMessage.TOKEN_INVALID)); 63 | } 64 | let nickname = null; 65 | let profileImage = null; 66 | const email = kakaoUserData.kakao_account.email; 67 | if (kakaoUserData.kakao_account) { 68 | nickname = kakaoUserData.kakao_account.profile.nickname; 69 | profileImage = kakaoUserData.kakao_account.profile.profile_image_url; 70 | } 71 | 72 | const firebaseUser = await updateOrCreateUser(KakaoUserId, email, nickname, profileImage); // Kakao 유저 정보를 가지고 있는 Firebase 유저 73 | const firebaseUserId = firebaseUser.uid; 74 | 75 | const firebaseUserData = { 76 | 'firebaseAuthToken' : await firebaseAdmin.auth().createCustomToken(firebaseUserId, { provider: 'KAKAO' }), 77 | 'firebaseUserId' : firebaseUserId, 78 | 'nickname' : nickname, 79 | 'email' : email, 80 | } 81 | return firebaseUserData; 82 | }; 83 | 84 | module.exports = { requestMe, updateOrCreateUser, createFirebaseToken }; -------------------------------------------------------------------------------- /functions/db/user.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const convertSnakeToCamel = require('../lib/convertSnakeToCamel'); 3 | 4 | const getUser = async (client, userId) => { 5 | const { rows } = await client.query( 6 | ` 7 | SELECT * FROM "user" 8 | WHERE id = $1 9 | `, 10 | [userId] 11 | ); 12 | return convertSnakeToCamel.keysToCamel(rows[0]); 13 | }; 14 | 15 | const getUserByFirebaseId = async (client, firebaseUserId) => { 16 | const { rows } = await client.query( 17 | ` 18 | SELECT * FROM "user" 19 | WHERE id_firebase = $1 20 | `, 21 | [firebaseUserId] 22 | ); 23 | return convertSnakeToCamel.keysToCamel(rows[0]); 24 | }; 25 | 26 | const addUser = async (client, firebaseUserId, nickname, email, age, gender, isOption, mongoId, refreshToken) => { 27 | const { rows } = await client.query( 28 | ` 29 | INSERT INTO "user" 30 | (id_firebase, nickname, email, age, gender, is_option, mongo_user_id, refresh_token) 31 | VALUES 32 | ($1, $2, $3, $4, $5, $6, $7, $8) 33 | RETURNING * 34 | `, 35 | [firebaseUserId, nickname, email, age, gender, isOption, mongoId, refreshToken] 36 | ); 37 | return convertSnakeToCamel.keysToCamel(rows[0]); 38 | }; 39 | 40 | const updateUserByLogin = async (client, firebaseUserId, nickname, email) => { 41 | const { rows } = await client.query( 42 | ` 43 | UPDATE "user" 44 | SET nickname = $2, email = $3 45 | WHERE id_firebase = $1 46 | RETURNING * 47 | `, 48 | [firebaseUserId, nickname, email] 49 | ); 50 | return convertSnakeToCamel.keysToCamel(rows[0]); 51 | }; 52 | 53 | const updateRefreshToken = async (client, userId, newRefreshToken) => { 54 | const { rows } = await client.query( 55 | ` 56 | UPDATE "user" 57 | SET refresh_token = $2 58 | WHERE id = $1 59 | `, 60 | [userId, newRefreshToken] 61 | ); 62 | }; 63 | 64 | const updateAppleRefreshToken = async (client, userId, appleRefreshToken) => { 65 | const { rows } = await client.query( 66 | ` 67 | UPDATE "user" 68 | SET apple_refresh_token = $2 69 | WHERE id = $1 70 | `, 71 | [userId, appleRefreshToken] 72 | ); 73 | }; 74 | 75 | const updateNickname = async (client, userId, newNickname) => { 76 | const { rows } = await client.query( 77 | ` 78 | UPDATE "user" 79 | SET nickname = $2, edited_at = now() 80 | WHERE id = $1 81 | `, 82 | [userId, newNickname] 83 | ); 84 | }; 85 | 86 | const deleteUser = async (client, userId, randomString) => { 87 | const { rows } = await client.query( 88 | ` 89 | UPDATE "user" 90 | SET is_deleted = TRUE, id_firebase = (id_firebase || $2) 91 | WHERE id = $1 92 | `, 93 | [userId, randomString] 94 | ) 95 | } 96 | 97 | module.exports = { getUser, getUserByFirebaseId, addUser, updateUserByLogin, updateRefreshToken, updateAppleRefreshToken, updateNickname, deleteUser }; -------------------------------------------------------------------------------- /functions/api/routes/auth/signupPOST.js: -------------------------------------------------------------------------------- 1 | const util = require("../../../lib/util"); 2 | const statusCode = require("../../../constants/statusCode"); 3 | const responseMessage = require("../../../constants/responseMessage"); 4 | const kakao = require("../../../lib/kakaoAuth"); 5 | const db = require("../../../db/db"); 6 | const { userDB } = require("../../../db"); 7 | const jwtHandlers = require("../../../lib/jwtHandlers"); 8 | const { createPushServerUser } = require("../../../lib/pushServerHandlers"); 9 | const { getAppleRefreshToken } = require("../../../lib/appleAuth"); 10 | const asyncWrapper = require('../../../lib/asyncWrapper'); 11 | 12 | /** 13 | * @route POST /auth/signup 14 | * @desc 신규 유저 회원가입 15 | * @access Public 16 | */ 17 | 18 | module.exports = asyncWrapper(async (req, res) => { 19 | const { fcmToken, kakaoAccessToken, firebaseUID, appleCode, nickname, email, age, gender, isOption } = req.body; 20 | 21 | if ((!fcmToken || (!firebaseUID && !kakaoAccessToken)) // fcmToken이 없거나 firebaseUID와 kakaoAccessToken이 모두 없을 때 22 | || (firebaseUID && kakaoAccessToken) // firebaseUID와 kakaoAccessToken이 모두 있을 때 23 | || (firebaseUID && !appleCode)) { // firebaseUID는 있으나 appleCode 가 없을 때 24 | const badRequestError = new Error(); 25 | badRequestError.statusCode = statusCode.BAD_REQUEST; 26 | badRequestError.responseMessage = (firebaseUID && kakaoAccessToken)? responseMessage.OUT_OF_VALUE : responseMessage.NULL_VALUE; 27 | throw badRequestError; 28 | } 29 | 30 | const dbConnection = await db.connect(req); 31 | req.dbConnection = dbConnection; 32 | const mongoId = await createPushServerUser(fcmToken); 33 | 34 | if (!firebaseUID) { 35 | // firebaseUID가 없을 때 : 카카오 소셜 로그인 36 | const firebaseUserData = await kakao.createFirebaseToken(kakaoAccessToken); 37 | const { firebaseAuthToken, firebaseUserId } = firebaseUserData; 38 | const refreshToken = jwtHandlers.signRefresh(); 39 | const kakaoUser = await userDB.addUser(dbConnection, firebaseUserId, nickname, email, age, gender, isOption, mongoId, refreshToken); 40 | const accessToken = jwtHandlers.sign({ id: kakaoUser.id, idFirebase: kakaoUser.idFirebase }); 41 | return res.status(statusCode.CREATED).send(util.success(statusCode.CREATED, responseMessage.SIGNUP_SUCCESS, { firebaseAuthToken, accessToken, refreshToken, id: kakaoUser.id, nickname })); 42 | } 43 | else { 44 | // kakaoAccessToken이 없을 때 (!kakaoAccessToken) : 애플 소셜 로그인 45 | const refreshToken = jwtHandlers.signRefresh(); 46 | const appleUser = await userDB.addUser(dbConnection, firebaseUID, nickname, email, age, gender, isOption, mongoId, refreshToken); 47 | const accessToken = jwtHandlers.sign({ id: appleUser.id, idFirebase: appleUser.idFirebase }); 48 | const firebaseAuthToken = ""; 49 | 50 | // apple refresh token 발급 51 | const appleRefreshToken = await getAppleRefreshToken(appleCode); 52 | await userDB.updateAppleRefreshToken(dbConnection, appleUser.id, appleRefreshToken); 53 | 54 | return res.status(statusCode.CREATED).send(util.success(statusCode.CREATED, responseMessage.SIGNUP_SUCCESS, { firebaseAuthToken, accessToken, refreshToken, id: appleUser.id, nickname })); 55 | } 56 | }); 57 | -------------------------------------------------------------------------------- /functions/api/routes/content/contentPOST.js: -------------------------------------------------------------------------------- 1 | const util = require('../../../lib/util'); 2 | const { createNotification } = require('../../../lib/pushServerHandlers'); 3 | const statusCode = require('../../../constants/statusCode'); 4 | const responseMessage = require('../../../constants/responseMessage'); 5 | const db = require('../../../db/db'); 6 | const { contentDB, categoryDB, categoryContentDB, userDB } = require('../../../db'); 7 | const dotenv = require('dotenv'); 8 | const dummyImages = require('../../../constants/dummyImages'); 9 | const asyncWrapper = require('../../../lib/asyncWrapper'); 10 | 11 | dotenv.config(); 12 | 13 | /** 14 | * @route POST /content 15 | * @desc 콘텐츠 생성 16 | * @access Private 17 | */ 18 | 19 | module.exports = asyncWrapper(async (req, res) => { 20 | 21 | const { title, description, url, isNotified, categoryIds } = req.body; 22 | let { image, notificationTime } = req.body; 23 | const { userId } = req.user; 24 | 25 | if (!title || !url || !categoryIds) { 26 | // 필수 데이터가 없을 경우 에러 처리 27 | return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, responseMessage.NULL_VALUE)); 28 | } 29 | 30 | if (!image) { 31 | // image url이 없는 경우, 더미 이미지 url로 변경 32 | image = dummyImages.content_dummy; 33 | } 34 | 35 | if (!notificationTime) { 36 | // notificationTime이 빈 문자열로 온 경우, null로 변경 37 | notificationTime = null; 38 | } 39 | 40 | const dbConnection = await db.connect(req); 41 | req.dbConnection = dbConnection; 42 | 43 | const user = await userDB.getUser(dbConnection, userId); 44 | let flag = true; // flag 변수 결과에 따라 categoryContent를 추가할 지, 에러를 보낼 지 결정 45 | for (const categoryId of categoryIds) { 46 | // 카테고리 배열의 id 중 하나라도 유저의 카테고리가 아닐 경우, categoryContent를 추가하지 않고 에러 전송 47 | const category = await categoryDB.getCategory(dbConnection, categoryId); 48 | if (!category || category.userId !== userId) { 49 | // 카테고리가 아예 존재하지 않거나, 해당 유저의 카테고리가 아닌 경우 50 | flag = false; 51 | } 52 | } 53 | 54 | if (flag) { 55 | // 유저가 해당 카테고리를 가지고 있을 때 56 | const content = await contentDB.addContent(dbConnection, userId, title, description, image, url, isNotified, notificationTime); 57 | for (const categoryId of categoryIds) { 58 | // 중복 카테고리 허용 59 | await categoryContentDB.addCategoryContent(dbConnection, categoryId, content.id); 60 | }; 61 | 62 | if (user.mongoUserId && content.isNotified) { 63 | const notificationData = { 64 | userId: user.mongoUserId, 65 | contentId: content.id, 66 | ogTitle: content.title, 67 | ogImage: content.image, 68 | url: content.url, 69 | time: notificationTime, 70 | isSeen: false, 71 | }; 72 | 73 | const response = await createNotification(notificationData); 74 | 75 | if (response.status !== 201) { 76 | return res.status(response.status).send(util.fail(response.status, responseMessage.PUSH_SERVER_ERROR)); 77 | } 78 | } 79 | 80 | res.status(statusCode.CREATED).send(util.success(statusCode.CREATED, responseMessage.ADD_ONE_CONTENT_SUCCESS, { contentId : content.id })); 81 | 82 | } else { 83 | // 유저가 해당 카테고리를 가지고 있지 않을 때 84 | res.status(statusCode.NOT_FOUND).send(util.fail(statusCode.NOT_FOUND, responseMessage.NO_CATEGORY)); 85 | } 86 | }); -------------------------------------------------------------------------------- /functions/constants/swagger/schemas/communitySchema.js: -------------------------------------------------------------------------------- 1 | const responseCommunityCategorySchema = { 2 | $status: 200, 3 | $success: true, 4 | $message: '커뮤니티 카테고리 조회 성공', 5 | $data: [ 6 | { 7 | $id: 1, 8 | $name: 'UI/UX', 9 | }, 10 | ], 11 | }; 12 | 13 | const responseCommunityPostsDetailSchema = { 14 | $status: 200, 15 | $success: true, 16 | $message: '커뮤니티 게시글 상세 조회 성공', 17 | $data: { 18 | $id: 1, 19 | $nickname: '잡채', 20 | $profileImage: 'https://s3~', 21 | $title: '제목', 22 | $body: '본문', 23 | $contentUrl: 'https://naver.com', 24 | $contentTitle: '콘텐츠 링크 제목', 25 | contentDescription: '콘텐츠 링크 설명', 26 | $thumbnailUrl: 'https://content-thumbnail-image-url', 27 | $createdAt: '2024. 02. 01', 28 | $isAuthor: true, 29 | }, 30 | }; 31 | 32 | const responseCommunityPostsSchema = { 33 | $status: 200, 34 | $success: true, 35 | $message: '커뮤니티 게시글 전체 조회 성공', 36 | $data: { 37 | $posts: [ 38 | { 39 | $id: 1, 40 | $nickname: '잡채', 41 | $profileImage: 'https://s3~', 42 | $title: '제목1', 43 | $body: '본문1', 44 | $contentUrl: 'https://naver.com', 45 | $contentTitle: '콘텐츠 링크 제목1', 46 | contentDescription: '콘텐츠 링크 설명1', 47 | $thumbnailUrl: 'https://content-thumbnail-image-url1', 48 | $createdAt: '2024. 02. 01', 49 | $isAuthor: true, 50 | }, 51 | { 52 | $id: 2, 53 | $nickname: '필립', 54 | $profileImage: 'https://s3~', 55 | $title: '제목2', 56 | $body: '본문2', 57 | $contentUrl: 'https://example2.com', 58 | $contentTitle: '콘텐츠 링크 제목2', 59 | contentDescription: '콘텐츠 링크 설명2', 60 | $thumbnailUrl: 'https://content-thumbnail-image-url2', 61 | $createdAt: '2024. 02. 02', 62 | $isAuthor: false, 63 | }, 64 | { 65 | $id: 3, 66 | $nickname: '윱최', 67 | $profileImage: 'https://s3~', 68 | $title: '제목3', 69 | $body: '본문3', 70 | $contentUrl: 'https://example3.com', 71 | $contentTitle: '콘텐츠 링크 제목3', 72 | contentDescription: '콘텐츠 링크 설명3', 73 | $thumbnailUrl: 'https://content-thumbnail-image-url3', 74 | $createdAt: '2024. 02. 03', 75 | $isAuthor: false, 76 | }, 77 | ], 78 | $currentPage: 1, 79 | $totalPageCount: 1, 80 | $totalItemCount: 3, 81 | $isLastPage: true, 82 | }, 83 | }; 84 | 85 | const responseCreateCommunityPostSchema = { 86 | $status: 201, 87 | $success: true, 88 | $message: '커뮤니티 게시글 작성 성공', 89 | }; 90 | 91 | const requestCreateCommunityPostSchema = { 92 | $communityCategoryIds: [1, 2, 3], 93 | $title: '게시글 제목', 94 | $body: '게시글 본문', 95 | $contentUrl: '공유하는 콘텐츠 링크', 96 | $contentTitle: '공유하는 콘텐츠 제목(og:title)', 97 | contentDescription: '공유하는 콘텐츠 description(og:description)', 98 | thumbnailUrl: '공유하는 콘텐츠 thumbnail(og:image)', 99 | }; 100 | 101 | const responseCommunityReportSchema = { 102 | $status: 201, 103 | $success: true, 104 | $message: '커뮤니티 게시글 신고 성공', 105 | }; 106 | 107 | const requestCommunityReportSchema = { 108 | $communityPostId: 1, 109 | }; 110 | 111 | module.exports = { 112 | responseCommunityCategorySchema, 113 | responseCommunityPostsDetailSchema, 114 | responseCommunityPostsSchema, 115 | responseCreateCommunityPostSchema, 116 | requestCreateCommunityPostSchema, 117 | responseCommunityReportSchema, 118 | requestCommunityReportSchema, 119 | }; 120 | -------------------------------------------------------------------------------- /functions/db/category.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const convertSnakeToCamel = require('../lib/convertSnakeToCamel'); 3 | 4 | const getAllCategories = async (client, userId) => { 5 | const { rows } = await client.query( 6 | ` 7 | SELECT c.id, c.title, c.order_index, i.id as image_id, i.url as image_url 8 | FROM category c 9 | JOIN category_image i on c.category_image_id = i.id 10 | WHERE user_id = $1 11 | AND is_deleted = FALSE 12 | ORDER BY c.order_index 13 | `, 14 | [userId] 15 | ); 16 | return convertSnakeToCamel.keysToCamel(rows); 17 | }; 18 | 19 | const getCategoryNames = async (client, userId) => { 20 | const { rows } = await client.query( 21 | ` 22 | SELECT c.title 23 | FROM category c 24 | WHERE user_id = $1 25 | AND is_deleted = FALSE 26 | ORDER BY c.order_index 27 | `, 28 | [userId] 29 | ); 30 | return convertSnakeToCamel.keysToCamel(rows); 31 | }; 32 | 33 | const addCategory = async (client, userId, title, imageId, order_index) => { 34 | const { rows } = await client.query( 35 | ` 36 | INSERT INTO category 37 | (user_id, title, category_image_id, order_index) 38 | VALUES 39 | ($1, $2, $3, $4) 40 | RETURNING * 41 | `, 42 | [userId, title, imageId, order_index], 43 | ); 44 | return convertSnakeToCamel.keysToCamel(rows[0]); 45 | }; 46 | 47 | const updateCategory = async (client, categoryId, title, imageId) => { 48 | const { rows: existingRows } = await client.query( 49 | ` 50 | SELECT * FROM category c 51 | WHERE id = $1 52 | AND is_deleted = FALSE 53 | `, 54 | [categoryId], 55 | ); 56 | 57 | if (existingRows.length === 0) return false; 58 | 59 | const data = _.merge({}, convertSnakeToCamel.keysToCamel(existingRows[0]), { title, imageId }); 60 | 61 | const { rows } = await client.query( 62 | ` 63 | UPDATE category c 64 | SET title = $1, category_image_id = $2, edited_at = now() 65 | WHERE id = $3 66 | RETURNING * 67 | `, 68 | [data.title, data.imageId, categoryId], 69 | ); 70 | return convertSnakeToCamel.keysToCamel(rows[0]); 71 | }; 72 | 73 | const deleteCategory = async (client, categoryId, userId) => { 74 | const { rows } = await client.query( 75 | ` 76 | UPDATE category 77 | SET is_deleted = true, edited_at = now() 78 | WHERE id = $1 AND user_id = $2 79 | `, 80 | [categoryId, userId] 81 | ); 82 | return convertSnakeToCamel.keysToCamel(rows[0]); 83 | }; 84 | 85 | const getCategory = async (client, categoryId) => { 86 | const { rows } = await client.query( 87 | ` 88 | SELECT * FROM category 89 | WHERE id = $1 90 | `, 91 | [categoryId] 92 | ); 93 | return convertSnakeToCamel.keysToCamel(rows[0]); 94 | }; 95 | 96 | const getCategoryByName = async (client, userId, title) => { 97 | const { rows } = await client.query( 98 | ` 99 | SELECT user_id, title FROM category 100 | WHERE user_id = $1 AND title = $2 101 | `, 102 | [userId, title] 103 | ); 104 | return convertSnakeToCamel.keysToCamel(rows[0]); 105 | }; 106 | 107 | const updateCategoryIndex = async (client, userId, contentId, orderIndex) => { 108 | const { rows } = await client.query( 109 | ` 110 | UPDATE category 111 | SET order_index = $3 112 | WHERE id = $2 AND user_id = $1 113 | `, 114 | [userId, contentId, orderIndex] 115 | ); 116 | }; 117 | 118 | module.exports = { getAllCategories, addCategory, getCategoryNames, updateCategory, deleteCategory, getCategory, getCategoryByName, updateCategoryIndex }; -------------------------------------------------------------------------------- /functions/api/routes/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | router.use( 5 | '/content', require('./content') 6 | /** 7 | * #swagger.tags = ['content'] 8 | * #swagger.responses[500] = { 9 | description: "Internal Server Error", 10 | content: { 11 | "application/json": { 12 | schema:{ 13 | $ref: "#/components/schemas/internalServerErrorSchema" 14 | } 15 | } 16 | } 17 | } 18 | */ 19 | ); 20 | router.use( 21 | '/category', require('./category') 22 | /** 23 | * #swagger.tags = ['category'] 24 | * #swagger.responses[500] = { 25 | description: "Internal Server Error", 26 | content: { 27 | "application/json": { 28 | schema:{ 29 | $ref: "#/components/schemas/internalServerErrorSchema" 30 | } 31 | } 32 | } 33 | } 34 | */ 35 | ); 36 | router.use( 37 | '/recommendation', require('./recommendation') 38 | /** 39 | * #swagger.tags = ['recommendation'] 40 | * #swagger.responses[500] = { 41 | description: "Internal Server Error", 42 | content: { 43 | "application/json": { 44 | schema:{ 45 | $ref: "#/components/schemas/internalServerErrorSchema" 46 | } 47 | } 48 | } 49 | } 50 | */ 51 | ); 52 | router.use( 53 | '/user', require('./user') 54 | /** 55 | * #swagger.tags = ['user'] 56 | * #swagger.responses[500] = { 57 | description: "Internal Server Error", 58 | content: { 59 | "application/json": { 60 | schema:{ 61 | $ref: "#/components/schemas/internalServerErrorSchema" 62 | } 63 | } 64 | } 65 | } 66 | */ 67 | ); 68 | router.use( 69 | '/auth', require('./auth') 70 | /** 71 | * #swagger.tags = ['auth'] 72 | * #swagger.responses[500] = { 73 | description: "Internal Server Error", 74 | content: { 75 | "application/json": { 76 | schema:{ 77 | $ref: "#/components/schemas/internalServerErrorSchema" 78 | } 79 | } 80 | } 81 | } 82 | */ 83 | ); 84 | router.use( 85 | '/notice', require('./notice') 86 | /** 87 | * #swagger.tags = ['notice'] 88 | * #swagger.responses[500] = { 89 | description: "Internal Server Error", 90 | content: { 91 | "application/json": { 92 | schema:{ 93 | $ref: "#/components/schemas/internalServerErrorSchema" 94 | } 95 | } 96 | } 97 | } 98 | */ 99 | 100 | ); 101 | router.use( 102 | '/health', require('./health') 103 | /** 104 | * #swagger.tags = ['health'] 105 | * #swagger.responses[500] = { 106 | description: "Internal Server Error", 107 | content: { 108 | "application/json": { 109 | schema:{ 110 | $ref: "#/components/schemas/internalServerErrorSchema" 111 | } 112 | } 113 | } 114 | } 115 | */ 116 | ); 117 | 118 | router.use( 119 | '/community', require('./community') 120 | /** 121 | * #swagger.tags = ['community'] 122 | * #swagger.responses[500] = { 123 | description: "Internal Server Error", 124 | content: { 125 | "application/json": { 126 | schema:{ 127 | $ref: "#/components/schemas/internalServerErrorSchema" 128 | } 129 | } 130 | } 131 | } 132 | */ 133 | ); 134 | 135 | module.exports = router; -------------------------------------------------------------------------------- /functions/api/routes/auth/signinPOST.js: -------------------------------------------------------------------------------- 1 | const util = require("../../../lib/util"); 2 | const statusCode = require("../../../constants/statusCode"); 3 | const responseMessage = require("../../../constants/responseMessage"); 4 | const kakao = require("../../../lib/kakaoAuth"); 5 | const db = require("../../../db/db"); 6 | const { userDB } = require("../../../db"); 7 | const jwtHandlers = require("../../../lib/jwtHandlers"); 8 | const { modifyFcmToken } = require('../../../lib/pushServerHandlers'); 9 | const { getAppleRefreshToken } = require("../../../lib/appleAuth"); 10 | const asyncWrapper = require('../../../lib/asyncWrapper'); 11 | 12 | /** 13 | * @route POST /auth/signin 14 | * @desc 기존 유저 로그인 15 | * @access Public 16 | */ 17 | 18 | module.exports = asyncWrapper(async (req, res) => { 19 | const { fcmToken, kakaoAccessToken, firebaseUID, appleCode } = req.body; 20 | 21 | if ((!fcmToken || (!firebaseUID && !kakaoAccessToken)) // fcmToken이 없거나 firebaseUID와 kakaoAccessToken이 모두 없을 때 22 | || (firebaseUID && kakaoAccessToken) // firebaseUID와 kakaoAccessToken이 모두 있을 때 23 | || (firebaseUID && !appleCode)) { // firebaseUID는 있으나 appleCode 가 없을 때 24 | const badRequestError = new Error(); 25 | badRequestError.statusCode = statusCode.BAD_REQUEST; 26 | badRequestError.responseMessage = (firebaseUID && kakaoAccessToken)? responseMessage.OUT_OF_VALUE : responseMessage.NULL_VALUE; 27 | throw badRequestError; 28 | } 29 | 30 | const dbConnection = await db.connect(req); 31 | req.dbConnection = dbConnection; 32 | let isAlreadyUser; 33 | 34 | if (!firebaseUID) { 35 | // firebaseUID가 없을 때 : 카카오 소셜 로그인 36 | const firebaseUserData = await kakao.createFirebaseToken(kakaoAccessToken); 37 | const { firebaseAuthToken, firebaseUserId } = firebaseUserData; 38 | const kakaoUser = await userDB.getUserByFirebaseId(dbConnection, firebaseUserId); 39 | 40 | if (!kakaoUser) { 41 | // 신규 사용자 42 | isAlreadyUser = false; 43 | return res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.NO_USER, { isAlreadyUser })); 44 | } 45 | else { 46 | // 기존 사용자 47 | isAlreadyUser = true; 48 | const accessToken = jwtHandlers.sign({ id: kakaoUser.id, idFirebase: kakaoUser.idFirebase }); 49 | const refreshToken = jwtHandlers.signRefresh(); 50 | const nickname = kakaoUser.nickname; 51 | 52 | const response = await modifyFcmToken(kakaoUser.mongoUserId, fcmToken); 53 | 54 | if (response.status !== 204) { 55 | return res.status(response.statusCode).send(util.fail(response.statusCode, responseMessage.PUSH_SERVER_ERROR)); 56 | } 57 | 58 | await userDB.updateRefreshToken(dbConnection, kakaoUser.id, refreshToken); 59 | 60 | return res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.SIGNIN_SUCCESS, 61 | { firebaseAuthToken, accessToken, refreshToken, id: kakaoUser.id, nickname })); 62 | }; 63 | } 64 | else { 65 | // kakaoAccessToken이 없을 때 (!kakaoAccessToken) : 애플 소셜 로그인 66 | const appleUser = await userDB.getUserByFirebaseId(dbConnection, firebaseUID); 67 | 68 | if (!appleUser) { 69 | // 신규 사용자 70 | isAlreadyUser = false; 71 | return res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.NO_USER, { isAlreadyUser })); 72 | } 73 | else { 74 | // 기존 사용자 75 | const accessToken = jwtHandlers.sign({ id: appleUser.id, idFirebase: appleUser.idFirebase }); 76 | const refreshToken = jwtHandlers.signRefresh(); 77 | const nickname = appleUser.nickname; 78 | const firebaseAuthToken = ""; 79 | 80 | // apple refresh token 발급 81 | const appleRefreshToken = await getAppleRefreshToken(appleCode); 82 | await userDB.updateAppleRefreshToken(dbConnection, appleUser.id, appleRefreshToken); 83 | 84 | const response = await modifyFcmToken(appleUser.mongoUserId, fcmToken); 85 | 86 | if (response.status !== 204) { 87 | return res.status(response.statusCode).send(util.fail(response.statusCode, responseMessage.PUSH_SERVER_ERROR)); 88 | } 89 | 90 | await userDB.updateRefreshToken(dbConnection, appleUser.id, refreshToken); 91 | 92 | return res.status(statusCode.OK).send(util.success(statusCode.OK, responseMessage.SIGNIN_SUCCESS, 93 | { firebaseAuthToken, accessToken, refreshToken, id: appleUser.id, nickname })); 94 | }; 95 | } 96 | }); 97 | -------------------------------------------------------------------------------- /.github/workflows/prod.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: firebase functions deploy - prod server 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the develop branch 8 | push: 9 | branches: [main] 10 | 11 | jobs: 12 | main: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Setting Slack API 19 | run: | 20 | touch deployMessageToSlack.sh 21 | echo 'curl -X POST -H 'Content-type: application/json' --data "{\"text\":\" [DEPLOY] [HAVIT_SERVER] deployment successful.\", \"icon_emoji\": \":ghost:\"}" ${{ secrets.PROD_WEB_HOOK_ERROR_MONITORING }}' >> deployMessageToSlack.sh 22 | chmod 755 deployMessageToSlack.sh 23 | 24 | - name: create env file 25 | run: | 26 | cd functions 27 | touch .env.prod 28 | echo "NODE_ENV=production" >> .env.prod 29 | echo "WEB_HOOK_ERROR_MONITORING=${{ secrets.PROD_WEB_HOOK_ERROR_MONITORING }}" >> .env.prod 30 | echo "DB_USER=${{ secrets.PROD_DB_USER }}" >> .env.prod 31 | echo "DB_HOST=${{ secrets.PROD_DB_HOST }}" >> .env.prod 32 | echo "DB_DB=${{ secrets.DB_DB }}" >> .env.prod 33 | echo "DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}" >> .env.prod 34 | echo "JWT_SECRET=${{ secrets.PROD_JWT_SECRET }}" >> .env.prod 35 | echo "TYPE=${{ secrets.TYPE }}" >> .env.prod 36 | echo "PROJECT_ID=${{ secrets.PROD_PROJECT_ID }}" >> .env.prod 37 | echo "PRIVATE_KEY=${{ secrets.PROD_PRIVATE_KEY }}" >> .env.prod 38 | echo "PRIVATE_KEY_ID=${{ secrets.PROD_PRIVATE_KEY_ID }}" >> .env.prod 39 | echo "CLIENT_EMAIL=${{ secrets.PROD_CLIENT_EMAIL }}" >> .env.prod 40 | echo "CLIENT_ID=${{ secrets.PROD_CLIENT_ID }}" >> .env.prod 41 | echo "AUTH_URI=${{ secrets.AUTH_URI }}" >> .env.prod 42 | echo "TOKEN_URI=${{ secrets.TOKEN_URI }}" >> .env.prod 43 | echo "AUTH_PROVIDER_CERT_URL=${{ secrets.AUTH_PROVIDER_CERT_URL }}" >> .env.prod 44 | echo "CLIENT_CERT_URL=${{ secrets.PROD_CLIENT_CERT_URL }}" >> .env.prod 45 | echo "PUSH_SERVER_URL=${{ secrets.PROD_PUSH_SERVER_URL }}" >> .env.prod 46 | echo "APPLE_CLIENT_ID=${{ secrets.APPLE_CLIENT_ID}}" >> .env.prod 47 | echo "APPLE_KEY_ID=${{ secrets.APPLE_KEY_ID }}" >> .env.prod 48 | echo "APPLE_TEAM_ID=${{ secrets.APPLE_TEAM_ID }}" >> .env.prod 49 | echo "APPLE_PRIVATE_KEY_FILE=${{ secrets.APPLE_PRIVATE_KEY_FILE }}" >> .env.prod 50 | echo "JWT_ALGORITHM=${{ secrets.JWT_ALGORITHM }}" >> .env.prod 51 | echo "JWT_ACCESS_EXPIRE=${{ secrets.JWT_ACCESS_EXPIRE }}" >> .env.prod 52 | echo "JWT_REFRESH_EXPIRE=${{ secrets.JWT_REFRESH_EXPIRE }} " >> .env.prod 53 | echo "SENTRY_DSN=${{ secrets.PROD_SENTRY_DSN }} " >> .env.prod 54 | echo "SENTRY_TRACES_SAMPLE_RATE=${{ secrets.PROD_SENTRY_TRACES_SAMPLE_RATE }} " >> .env.prod 55 | 56 | - name: create .p8 file 57 | run: | 58 | cd functions 59 | touch ${{ secrets.APPLE_PRIVATE_KEY_FILE }} 60 | echo "${{ secrets.APPLE_PRIVATE_KEY }}" > ${{ secrets.APPLE_PRIVATE_KEY_FILE }} 61 | 62 | - name: create-json 63 | id: create-dev-json 64 | uses: jsdaniell/create-json@1.1.2 65 | with: 66 | name: "havit-wesopt29-firebase-adminsdk-mgljp-478046b091.json" 67 | json: ${{ secrets.FIREBASE_JSON }} 68 | dir: "functions/" 69 | 70 | - name: create-json 71 | id: create-prod-json 72 | uses: jsdaniell/create-json@v1.1.2 73 | with: 74 | name: "havit-production-firebase-adminsdk-bypl1-d081cc62e4.json" 75 | json: ${{ secrets.PROD_FIREBASE_JSON }} 76 | dir: "functions/" 77 | 78 | - name: Install npm pacakges 79 | run: | 80 | cd functions 81 | npm install 82 | npm install -g firebase-tools 83 | npm install --save-dev cross-env 84 | 85 | - name: Create swagger output file 86 | run: | 87 | cd functions 88 | npm run swagger 89 | 90 | - name: Deploy to Firebase 91 | run: | 92 | cd functions 93 | firebase use prod 94 | npm run deploy 95 | env: 96 | FIREBASE_TOKEN: ${{ secrets.PROD_FIREBASE_TOKEN }} 97 | 98 | - name: action-slack 99 | uses: 8398a7/action-slack@v3 100 | with: 101 | status: ${{ job.status }} 102 | author_name: Github Actions Dev Prod Server 103 | fields: repo,message,commit,author,action,eventName,ref,workflow,job,took 104 | env: 105 | SLACK_WEBHOOK_URL: ${{ secrets.PROD_SLACK_BUILD_WEBHOOK_URL }} # required 106 | if: always() # Pick up events even if the job fails or is canceled. 107 | -------------------------------------------------------------------------------- /.github/workflows/dev.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: firebase functions deploy - dev server 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the develop branch 8 | push: 9 | branches: [ develop ] 10 | 11 | jobs: 12 | main: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | 17 | - uses: actions/checkout@v2 18 | 19 | - name: Setting Slack API 20 | run: | 21 | touch deployMessageToSlack.sh 22 | echo 'curl -X POST -H 'Content-type: application/json' --data "{\"text\":\" [DEPLOY] [HAVIT_SERVER] deployment successful.\", \"icon_emoji\": \":ghost:\"}" ${{ secrets.DEV_WEB_HOOK_ERROR_MONITORING }}' >> deployMessageToSlack.sh 23 | chmod 755 deployMessageToSlack.sh 24 | 25 | - name: create env file 26 | run: | 27 | cd functions 28 | touch .env.dev 29 | echo "NODE_ENV=development" >> .env.dev 30 | echo "WEB_HOOK_ERROR_MONITORING=${{ secrets.DEV_WEB_HOOK_ERROR_MONITORING }}" >> .env.dev 31 | echo "DB_USER=${{ secrets.DB_USER }}" >> .env.dev 32 | echo "DB_HOST=${{ secrets.DB_HOST }}" >> .env.dev 33 | echo "DB_DB=${{ secrets.DB_DB }}" >> .env.dev 34 | echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> .env.dev 35 | echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env.dev 36 | echo "TYPE=${{ secrets.TYPE }}" >> .env.dev 37 | echo "PROJECT_ID=${{ secrets.PROJECT_ID }}" >> .env.dev 38 | echo "PRIVATE_KEY=${{ secrets.PRIVATE_KEY }}" >> .env.dev 39 | echo "PRIVATE_KEY_ID=${{ secrets.PRIVATE_KEY_ID }}" >> .env.dev 40 | echo "CLIENT_EMAIL=${{ secrets.CLIENT_EMAIL }}" >> .env.dev 41 | echo "CLIENT_ID=${{ secrets.CLIENT_ID }}" >> .env.dev 42 | echo "AUTH_URI=${{ secrets.AUTH_URI }}" >> .env.dev 43 | echo "TOKEN_URI=${{ secrets.TOKEN_URI }}" >> .env.dev 44 | echo "AUTH_PROVIDER_CERT_URL=${{ secrets.AUTH_PROVIDER_CERT_URL }}" >> .env.dev 45 | echo "CLIENT_CERT_URL=${{ secrets.CLIENT_CERT_URL }}" >> .env.dev 46 | echo "PUSH_SERVER_URL=${{ secrets.PUSH_SERVER_URL }}" >> .env.dev 47 | echo "APPLE_CLIENT_ID=${{ secrets.APPLE_CLIENT_ID}}" >> .env.dev 48 | echo "APPLE_KEY_ID=${{ secrets.APPLE_KEY_ID }}" >> .env.dev 49 | echo "APPLE_TEAM_ID=${{ secrets.APPLE_TEAM_ID }}" >> .env.dev 50 | echo "APPLE_PRIVATE_KEY_FILE=${{ secrets.APPLE_PRIVATE_KEY_FILE }}" >> .env.dev 51 | echo "JWT_ALGORITHM=${{ secrets.JWT_ALGORITHM }}" >> .env.dev 52 | echo "JWT_ACCESS_EXPIRE=${{ secrets.JWT_ACCESS_EXPIRE }}" >> .env.dev 53 | echo "JWT_REFRESH_EXPIRE=${{ secrets.JWT_REFRESH_EXPIRE }} " >> .env.dev 54 | echo "SENTRY_DSN=${{ secrets.DEV_SENTRY_DSN }} " >> .env.dev 55 | echo "SENTRY_TRACES_SAMPLE_RATE=${{ secrets.DEV_SENTRY_TRACES_SAMPLE_RATE }} " >> .env.dev 56 | echo "DEV_HOST=${{ secrets.DEV_HOST }} " >> .env.dev 57 | 58 | - name: create .p8 file 59 | run: | 60 | cd functions 61 | touch ${{ secrets.APPLE_PRIVATE_KEY_FILE }} 62 | echo "${{ secrets.APPLE_PRIVATE_KEY }}" >> ${{ secrets.APPLE_PRIVATE_KEY_FILE }} 63 | 64 | - name: create-json 65 | id: create-dev-json 66 | uses: jsdaniell/create-json@1.1.2 67 | with: 68 | name: "havit-wesopt29-firebase-adminsdk-mgljp-478046b091.json" 69 | json: ${{ secrets.FIREBASE_JSON }} 70 | dir: 'functions/' 71 | 72 | - name: create-json 73 | id: create-prod-json 74 | uses: jsdaniell/create-json@v1.1.2 75 | with: 76 | name: "havit-production-firebase-adminsdk-bypl1-d081cc62e4.json" 77 | json: ${{ secrets.PROD_FIREBASE_JSON }} 78 | dir: 'functions/' 79 | 80 | - name: Install npm pacakges 81 | run: | 82 | cd functions 83 | npm ci 84 | npm install -g firebase-tools 85 | npm install --save-dev cross-env 86 | 87 | - name: Create swagger output file 88 | run: | 89 | cd functions 90 | npm run swagger 91 | 92 | - name: Deploy to Firebase 93 | run: | 94 | cd functions 95 | firebase use dev 96 | npm run dev 97 | env: 98 | FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} 99 | 100 | - name: action-slack 101 | uses: 8398a7/action-slack@v3 102 | with: 103 | status: ${{ job.status }} 104 | author_name: Github Actions Dev App Server 105 | fields: repo,message,commit,author,action,eventName,ref,workflow,job,took 106 | env: 107 | SLACK_WEBHOOK_URL: ${{ secrets.DEV_SLACK_BUILD_WEBHOOK_URL }} # required 108 | if: always() # Pick up events even if the job fails or is canceled. 109 | -------------------------------------------------------------------------------- /functions/db/categoryContent.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const convertSnakeToCamel = require('../lib/convertSnakeToCamel'); 3 | 4 | const getAllCategoryContentByFilter = async (client, userId, categoryId, filter) => { 5 | if (filter === "reverse") { 6 | // API 로직에서 reverse 할 것이므로 created_at 기준으로 정렬한다. 7 | filter = "created_at"; 8 | } 9 | const { rows } = await client.query( 10 | ` 11 | SELECT c2.id, c2.title, c2.image, c2.description, c2.url, c2.is_seen, c2.is_notified, c2.notification_time, c2.created_at, c2.seen_at 12 | FROM category c 13 | JOIN category_content cc on c.id = cc.category_id 14 | JOIN content c2 on cc.content_id = c2.id 15 | WHERE c.id = $2 AND c2.user_id = $1 AND c2.is_deleted = FALSE 16 | ORDER BY ${filter} DESC 17 | `, 18 | [userId, categoryId] 19 | ); 20 | return convertSnakeToCamel.keysToCamel(rows); 21 | }; 22 | 23 | const getCategoryContentByFilterAndNotified = async (client, userId, categoryId, option, filter) => { 24 | if (filter === "reverse") { 25 | // API 로직에서 reverse 할 것이므로 createdAt 기준으로 정렬한다. 26 | filter = "created_at"; 27 | } 28 | const { rows } = await client.query( 29 | ` 30 | SELECT c2.id, c2.title, c2.image, c2.description, c2.url, c2.is_seen, c2.is_notified, c2.notification_time, c2.created_at, c2.seen_at 31 | FROM category c 32 | JOIN category_content cc on c.id = cc.category_id 33 | JOIN content c2 on cc.content_id = c2.id 34 | WHERE c.id = $2 AND c2.user_id = $1 AND c2.is_deleted = FALSE AND c2.is_notified = ${option} AND c2.notification_time > NOW() 35 | ORDER BY ${filter} DESC 36 | `, 37 | [userId, categoryId] 38 | ); 39 | return convertSnakeToCamel.keysToCamel(rows); 40 | }; 41 | 42 | const getCategoryContentByFilterAndSeen = async (client, userId, categoryId, option, filter) => { 43 | if (filter === "reverse") { 44 | // API 로직에서 reverse 할 것이므로 createdAt 기준으로 정렬한다. 45 | filter = "created_at"; 46 | } 47 | const { rows } = await client.query( 48 | ` 49 | SELECT c2.id, c2.title, c2.image, c2.description, c2.url, c2.is_seen, c2.is_notified, c2.notification_time, c2.created_at, c2.seen_at 50 | FROM category c 51 | JOIN category_content cc on c.id = cc.category_id 52 | JOIN content c2 on cc.content_id = c2.id 53 | WHERE c.id = $2 AND c2.user_id = $1 AND c2.is_deleted = FALSE AND c2.is_seen = ${option} 54 | ORDER BY ${filter} DESC 55 | `, 56 | [userId, categoryId] 57 | ); 58 | return convertSnakeToCamel.keysToCamel(rows); 59 | }; 60 | 61 | const getCategoryContentByContentId = async (client, contentId, userId) => { 62 | const { rows } = await client.query( 63 | ` 64 | SELECT c.id, c.title, c.order_index, i.id as image_id, i.url as image_url 65 | FROM category_content cc 66 | JOIN category c on c.id = cc.category_id 67 | JOIN category_image i on c.category_image_id = i.id 68 | WHERE cc.content_id = $1 AND c.user_id = $2 69 | ORDER BY c.order_index 70 | `, 71 | [contentId, userId] 72 | ); 73 | return convertSnakeToCamel.keysToCamel(rows); 74 | } 75 | 76 | const addCategoryContent = async (client, categoryId, contentId) => { 77 | const { rows } = await client.query( 78 | ` 79 | INSERT INTO category_content 80 | (category_id, content_id) 81 | VALUES 82 | ($1, $2) 83 | `, 84 | [categoryId, contentId] 85 | ); 86 | return convertSnakeToCamel.keysToCamel(rows[0]); 87 | }; 88 | 89 | const deleteCategoryContentByCategoryId = async (client, categoryId) => { 90 | const { rows } = await client.query( 91 | ` 92 | DELETE 93 | FROM category_content 94 | WHERE category_id = $1 95 | `, 96 | [categoryId] 97 | ); 98 | return convertSnakeToCamel.keysToCamel(rows); 99 | }; 100 | 101 | const deleteCategoryContentByContentId = async (client, contentId) => { 102 | const { rows } = await client.query( 103 | ` 104 | DELETE 105 | FROM category_content 106 | WHERE content_id = $1 107 | RETURNING * 108 | `, 109 | [contentId] 110 | ); 111 | return convertSnakeToCamel.keysToCamel(rows[0]); 112 | }; 113 | 114 | const searchCategoryContent = async (client, userId, categoryId, keyword) => { 115 | const searchKeyword = '%' + keyword + '%'; 116 | const { rows } = await client.query( 117 | ` 118 | SELECT co.id, co.title, co.image, co.description, co.url, co.is_seen, co.is_notified, co.notification_time, co.created_at, co.seen_at 119 | FROM content co 120 | JOIN category_content cc on co.id = cc.content_id 121 | JOIN category ca on ca.id = cc.category_id 122 | WHERE ca.id = $2 AND co.user_id = $1 AND co.is_deleted = FALSE AND co.title ilike $3 123 | ORDER BY co.created_at DESC 124 | `, 125 | [userId, categoryId, searchKeyword] 126 | ); 127 | return convertSnakeToCamel.keysToCamel(rows); 128 | }; 129 | 130 | module.exports = { getAllCategoryContentByFilter, getCategoryContentByFilterAndNotified, getCategoryContentByFilterAndSeen, getCategoryContentByContentId, addCategoryContent, 131 | deleteCategoryContentByCategoryId, deleteCategoryContentByContentId, searchCategoryContent }; -------------------------------------------------------------------------------- /functions/api/routes/community/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { checkUser } = require('../../../middlewares/auth'); 4 | const { validate } = require('../../../middlewares/validation'); 5 | const { communityValidator } = require('../../../middlewares/validator'); 6 | 7 | router.get( 8 | '/categories', 9 | require('./communityCategoriesGET'), 10 | /** 11 | * #swagger.summary = "커뮤니티 카테고리 전체 조회" 12 | * #swagger.responses[200] = { 13 | description: "커뮤니티 카테고리 조회 성공", 14 | content: { 15 | "application/json": { 16 | schema:{ 17 | $ref: "#/components/schemas/responseCommunityCategorySchema" 18 | } 19 | } 20 | } 21 | } 22 | */ 23 | ); 24 | 25 | router.get( 26 | '/categories/:communityCategoryId', 27 | checkUser, 28 | [...communityValidator.getCommunityCategoryPostsValidator, validate], 29 | require('./communityCategoryPostsGET'), 30 | /** 31 | * #swagger.summary = "커뮤니티 카테고리별 게시글 조회" 32 | * #swagger.parameters['page'] = { 33 | in: 'query', 34 | description: '페이지 번호', 35 | type: 'number', 36 | required: true 37 | } 38 | * #swagger.parameters['limit'] = { 39 | in: 'query', 40 | description: '페이지 당 게시글 수', 41 | type: 'number', 42 | required: true 43 | } 44 | * #swagger.responses[200] = { 45 | description: "커뮤니티 게시글 카테고리별 조회 성공", 46 | content: { 47 | "application/json": { 48 | schema:{ 49 | $ref: "#/components/schemas/responseCommunityPostsSchema" 50 | } 51 | } 52 | } 53 | } 54 | * #swagger.responses[400] 55 | * #swagger.responses[404] 56 | */ 57 | ); 58 | 59 | router.get( 60 | '/posts/:communityPostId', 61 | checkUser, 62 | [...communityValidator.getCommunityPostValidator, validate], 63 | require('./communityPostGET'), 64 | /** 65 | * #swagger.summary = "커뮤니티 게시글 상세 조회" 66 | * #swagger.parameters['communityPostId'] = { 67 | in: 'path', 68 | description: '커뮤니티 게시글 아이디', 69 | type: 'number', 70 | required: true 71 | } 72 | * #swagger.responses[200] = { 73 | description: "커뮤니티 게시글 상세 조회 성공", 74 | content: { 75 | "application/json": { 76 | schema:{ 77 | $ref: "#/components/schemas/responseCommunityPostsDetailSchema" 78 | } 79 | } 80 | } 81 | } 82 | * #swagger.responses[400] 83 | * #swagger.responses[404] 84 | */ 85 | ); 86 | 87 | router.get( 88 | '/posts', 89 | checkUser, 90 | [...communityValidator.getCommunityPostsValidator, validate], 91 | require('./communityPostsGET'), 92 | /** 93 | * #swagger.summary = "커뮤니티 게시글 전체 조회" 94 | * #swagger.parameters['page'] = { 95 | in: 'query', 96 | description: '페이지 번호', 97 | type: 'number', 98 | required: true 99 | } 100 | * #swagger.parameters['limit'] = { 101 | in: 'query', 102 | description: '페이지 당 게시글 수', 103 | type: 'number', 104 | required: true 105 | } 106 | * #swagger.responses[200] = { 107 | description: "커뮤니티 게시글 전체 조회 성공", 108 | content: { 109 | "application/json": { 110 | schema:{ 111 | $ref: "#/components/schemas/responseCommunityPostsSchema" 112 | } 113 | } 114 | } 115 | } 116 | * #swagger.responses[400] 117 | * #swagger.responses[404] 118 | */ 119 | ); 120 | 121 | router.post( 122 | '/posts', 123 | checkUser, 124 | [...communityValidator.createCommunityPostValidator, validate], 125 | require('./communityPostPOST'), 126 | /** 127 | * #swagger.summary = "커뮤니티 글 작성" 128 | * #swagger.requestBody = { 129 | required: true, 130 | content: { 131 | "application/json": { 132 | schema:{ 133 | $ref: "#/components/schemas/requestCreateCommunityPostSchema" 134 | } 135 | } 136 | } 137 | } 138 | * #swagger.responses[201] = { 139 | description: "커뮤니티 게시글 작성 성공", 140 | content: { 141 | "application/json": { 142 | schema:{ 143 | $ref: "#/components/schemas/responseCreateCommunityPostSchema" 144 | } 145 | } 146 | } 147 | } 148 | * #swagger.responses[400] 149 | */ 150 | ); 151 | 152 | router.post( 153 | '/reports', 154 | checkUser, 155 | [...communityValidator.reportCommunityPostValidator, validate], 156 | require('./communityReportPOST'), 157 | /** 158 | * #swagger.summary = "커뮤니티 게시글 신고" 159 | * #swagger.requestBody = { 160 | required: true, 161 | content: { 162 | "application/json": { 163 | schema:{ 164 | $ref: "#/components/schemas/requestCommunityReportSchema" 165 | } 166 | } 167 | } 168 | } 169 | * #swagger.responses[201] = { 170 | description: "커뮤니티 게시글 신고 성공", 171 | content: { 172 | "application/json": { 173 | schema:{ 174 | $ref: "#/components/schemas/responseCommunityReportSchema" 175 | } 176 | } 177 | } 178 | } 179 | * #swagger.responses[400] 180 | * #swagger.responses[404] 181 | */ 182 | ); 183 | 184 | router.delete( 185 | '/:communityPostId', 186 | checkUser, 187 | [...communityValidator.deleteCommunityPostValidator, validate], 188 | require('./communityPostDELETE'), 189 | /** 190 | * #swagger.summary = "커뮤니티 게시글 삭제" 191 | * #swagger.parameters['communityPostId'] = { 192 | in: 'path', 193 | description: '커뮤니티 게시글 아이디', 194 | type: 'number', 195 | required: true 196 | } 197 | * #swagger.responses[204] = { 198 | description: "커뮤니티 게시글 삭제 성공", 199 | } 200 | * #swagger.responses[400] 201 | * #swagger.responses[403] 202 | * #swagger.responses[404] 203 | */ 204 | ); 205 | 206 | module.exports = router; 207 | -------------------------------------------------------------------------------- /test/category.spec.js: -------------------------------------------------------------------------------- 1 | const app = require('../functions/api/index'); 2 | const request = require('supertest'); 3 | const { expect } = require('chai'); 4 | const dotenv = require('dotenv'); 5 | dotenv.config(); 6 | 7 | describe('PATCH /category/order', () => { 8 | it('카테고리 순서 변경 성공', done => { 9 | request(app) 10 | .patch('/category/order') 11 | .set('Content-Type', 'application/json') 12 | .set('x-auth-token', process.env.JWT_TOKEN) 13 | .send({ 14 | "categoryIndexArray": [7, 6, 10, 9, 21] 15 | }) 16 | .expect(200) 17 | .expect('Content-Type', /json/) 18 | .then(res => { 19 | done(); 20 | }) 21 | .catch(err => { 22 | console.error("######Error >>", err); 23 | done(err); 24 | }) 25 | }); 26 | it('카테고리 순서 변경 - 필요한 값 없음', done => { 27 | request(app) 28 | .patch('/category/order') 29 | .set('Content-Type', 'application/json') 30 | .set('x-auth-token', process.env.JWT_TOKEN) 31 | .send({ 32 | "categoryIndex": [] 33 | }) 34 | .expect(400) 35 | .then(res => { 36 | done(); 37 | }) 38 | .catch(err => { 39 | console.error("######Error >>", err); 40 | done(err); 41 | }) 42 | }); 43 | }); 44 | 45 | describe('GET /category', () => { 46 | it('카테고리 전체 조회 성공', done => { 47 | request(app) 48 | .get('/category') 49 | .set('Content-Type', 'application/json') 50 | .set('x-auth-token', process.env.JWT_TOKEN) 51 | .expect(200) 52 | .expect('Content-Type', /json/) 53 | .then(res => { 54 | done(); 55 | }) 56 | .catch(err => { 57 | console.error("######Error >>", err); 58 | done(err); 59 | }) 60 | }); 61 | }); 62 | 63 | describe('POST /category', () => { 64 | it('카테고리 생성 성공', done => { 65 | request(app) 66 | .post('/category') 67 | .set('Content-Type', 'application/json') 68 | .set('x-auth-token', process.env.JWT_TOKEN) 69 | .send({ 70 | "title": "취미 생활", 71 | "imageId": 8 72 | }) 73 | .expect(201) 74 | .expect('Content-Type', /json/) 75 | .then(res => { 76 | done(); 77 | }) 78 | .catch(err => { 79 | console.error("######Error >>", err); 80 | done(err); 81 | }) 82 | }); 83 | it('카테고리 생성 - 필요한 값 없음', done => { 84 | request(app) 85 | .post('/category') 86 | .set('Content-Type', 'application/json') 87 | .set('x-auth-token', process.env.JWT_TOKEN) 88 | .send({ 89 | "title": "취미 생활" 90 | }) 91 | .expect(400) 92 | .expect('Content-Type', /json/) 93 | .then(res => { 94 | done(); 95 | }) 96 | .catch(err => { 97 | console.error("######Error >>", err); 98 | done(err); 99 | }) 100 | }); 101 | }); 102 | 103 | describe('PATCH /category/:categoryId', () => { 104 | it('카테고리 수정 성공', done => { 105 | request(app) 106 | .patch('/category/9') 107 | .set('Content-Type', 'application/json') 108 | .set('x-auth-token', process.env.JWT_TOKEN) 109 | .send({ 110 | "title": "기획", 111 | "imageId": 9 112 | }) 113 | .expect(200) 114 | .expect('Content-Type', /json/) 115 | .then(res => { 116 | done(); 117 | }) 118 | .catch(err => { 119 | console.error("######Error >>", err); 120 | done(err); 121 | }) 122 | }); 123 | it('카테고리 수정 - 필요한 값 없음', done => { 124 | request(app) 125 | .patch('/category/9') 126 | .set('Content-Type', 'application/json') 127 | .set('x-auth-token', process.env.JWT_TOKEN) 128 | .send({ 129 | "imageId": 5 130 | }) 131 | .expect(400) 132 | .then(res => { 133 | done(); 134 | }) 135 | .catch(err => { 136 | console.error("######Error >>", err); 137 | done(err); 138 | }) 139 | }); 140 | }); 141 | 142 | describe('DELETE /category/:categoryId', () => { 143 | it('카테고리 삭제 성공', done => { 144 | request(app) 145 | .delete('/category/11') 146 | .set('Content-Type', 'application/json') 147 | .set('x-auth-token', process.env.JWT_TOKEN) 148 | .expect(200) 149 | .expect('Content-Type', /json/) 150 | .then(res => { 151 | done(); 152 | }) 153 | .catch(err => { 154 | console.error("######Error >>", err); 155 | done(err); 156 | }) 157 | }); 158 | }); 159 | 160 | describe('GET /category/:categoryId?option=filter=', () => { 161 | it('카테고리 별 콘텐츠 성공 - option=all, filter=created_at', done => { 162 | request(app) 163 | .get('/category/9?option=all&filter=created_at') 164 | .set('Content-Type', 'application/json') 165 | .set('x-auth-token', process.env.JWT_TOKEN) 166 | .expect(200) 167 | .expect('Content-Type', /json/) 168 | .then(res => { 169 | done(); 170 | }) 171 | .catch(err => { 172 | console.error("######Error >>", err); 173 | done(err); 174 | }) 175 | }); 176 | it('카테고리 별 콘텐츠 성공 - option=true, filter=seen_at', done => { 177 | request(app) 178 | .get('/category/9?option=true&filter=seen_at') 179 | .set('Content-Type', 'application/json') 180 | .set('x-auth-token', process.env.JWT_TOKEN) 181 | .expect(200) 182 | .expect('Content-Type', /json/) 183 | .then(res => { 184 | done(); 185 | }) 186 | .catch(err => { 187 | console.error("######Error >>", err); 188 | done(err); 189 | }) 190 | }); 191 | it('카테고리 별 콘텐츠 성공 - option=false, filter=reverse', done => { 192 | request(app) 193 | .get('/category/9?option=false&filter=reverse') 194 | .set('Content-Type', 'application/json') 195 | .set('x-auth-token', process.env.JWT_TOKEN) 196 | .expect(200) 197 | .expect('Content-Type', /json/) 198 | .then(res => { 199 | done(); 200 | }) 201 | .catch(err => { 202 | console.error("######Error >>", err); 203 | done(err); 204 | }) 205 | }); 206 | it('카테고리 별 콘텐츠 성공 - option=notified, filter=created_at', done => { 207 | request(app) 208 | .get('/category/9?option=notified&filter=created_at') 209 | .set('Content-Type', 'application/json') 210 | .set('x-auth-token', process.env.JWT_TOKEN) 211 | .expect(200) 212 | .expect('Content-Type', /json/) 213 | .then(res => { 214 | done(); 215 | }) 216 | .catch(err => { 217 | console.error("######Error >>", err); 218 | done(err); 219 | }) 220 | }); 221 | }); 222 | 223 | describe('GET /category/name', () => { 224 | it('카테고리 이름 조회 성공', done => { 225 | request(app) 226 | .get('/category/name') 227 | .set('Content-Type', 'application/json') 228 | .set('x-auth-token', process.env.JWT_TOKEN) 229 | .expect(200) 230 | .expect('Content-Type', /json/) 231 | .then(res => { 232 | done(); 233 | }) 234 | .catch(err => { 235 | console.error("######Error >>", err); 236 | done(err); 237 | }) 238 | }); 239 | }); -------------------------------------------------------------------------------- /functions/db/community.js: -------------------------------------------------------------------------------- 1 | const convertSnakeToCamel = require('../lib/convertSnakeToCamel'); 2 | 3 | const getCommunityPostDetail = async (client, communityPostId, userId) => { 4 | const { rows } = await client.query( 5 | ` 6 | SELECT cp.id, u.nickname, cp.title, cp.body, cp.content_url, cp.content_title, cp.content_description, cp.thumbnail_url, cp.created_at, 7 | CASE WHEN cp.user_id = $2 THEN TRUE ELSE FALSE END as is_author 8 | FROM community_post cp 9 | JOIN "user" u on cp.user_id = u.id 10 | WHERE cp.id = $1 AND cp.is_deleted = FALSE 11 | `, 12 | [communityPostId, userId], 13 | ); 14 | 15 | return convertSnakeToCamel.keysToCamel(rows[0]); 16 | }; 17 | 18 | const getCommunityPosts = async (client, userId, limit, offset) => { 19 | const { rows } = await client.query( 20 | ` 21 | SELECT cp.id, u.nickname, cp.title, cp.body, cp.content_url, cp.content_title, cp.content_description, cp.thumbnail_url, cp.created_at, 22 | CASE WHEN cp.user_id = $1 THEN TRUE ELSE FALSE END as is_author 23 | FROM community_post cp 24 | JOIN "user" u ON cp.user_id = u.id 25 | LEFT JOIN community_post_report_user cpru ON cp.id = cpru.community_post_id AND cpru.report_user_id = $1 26 | WHERE cp.is_deleted = FALSE AND cpru.id IS NULL 27 | ORDER BY cp.created_at DESC, cp.id DESC 28 | LIMIT $2 OFFSET $3 29 | `, 30 | [userId, limit, offset], 31 | ); 32 | return convertSnakeToCamel.keysToCamel(rows); 33 | }; 34 | 35 | const addCommunityPost = async ( 36 | client, 37 | userId, 38 | title, 39 | body, 40 | contentUrl, 41 | contentTitle, 42 | contentDescription, 43 | thumbnailUrl, 44 | ) => { 45 | const { rows } = await client.query( 46 | ` 47 | INSERT INTO community_post 48 | (user_id, title, body, content_url, content_title, content_description, thumbnail_url) 49 | VALUES 50 | ($1, $2, $3, $4, $5, $6, $7) 51 | RETURNING * 52 | `, 53 | [userId, title, body, contentUrl, contentTitle, contentDescription, thumbnailUrl], 54 | ); 55 | return convertSnakeToCamel.keysToCamel(rows[0]); 56 | }; 57 | 58 | const addCommunityCategoryPost = async (client, communityCategoryId, communityPostId) => { 59 | const { rows } = await client.query( 60 | ` 61 | INSERT INTO community_category_post 62 | (community_category_id, community_post_id) 63 | VALUES 64 | ($1, $2) 65 | `, 66 | [communityCategoryId, communityPostId], 67 | ); 68 | }; 69 | 70 | const verifyExistCategories = async (client, communityCategoryIds) => { 71 | const { rows } = await client.query( 72 | ` 73 | SELECT element 74 | FROM unnest($1::int[]) AS element 75 | LEFT JOIN community_category ON community_category.id = element 76 | WHERE community_category.id IS NULL; 77 | `, 78 | [communityCategoryIds], 79 | ); 80 | 81 | return convertSnakeToCamel.keysToCamel(rows[0]); 82 | }; 83 | 84 | const isExistingCategory = async (client, communityCategoryId) => { 85 | const { rows } = await client.query( 86 | ` 87 | SELECT 1 88 | FROM community_category 89 | WHERE id = $1 AND is_deleted = FALSE 90 | `, 91 | [communityCategoryId], 92 | ); 93 | 94 | return convertSnakeToCamel.keysToCamel(rows[0]); 95 | }; 96 | 97 | const getCommunityCategories = async (client) => { 98 | const { rows } = await client.query( 99 | ` 100 | SELECT cc.id, cc.name 101 | FROM community_category cc 102 | WHERE cc.is_deleted = FALSE 103 | `, 104 | ); 105 | 106 | return convertSnakeToCamel.keysToCamel(rows); 107 | }; 108 | 109 | const getCommunityPostsCount = async (client, userId) => { 110 | const { rows } = await client.query( 111 | ` 112 | SELECT COUNT(*)::int 113 | FROM community_post cp 114 | LEFT JOIN community_post_report_user cpru ON cp.id = cpru.community_post_id AND cpru.report_user_id = $1 115 | WHERE cp.is_deleted = FALSE AND cpru.id IS NULL 116 | `, 117 | [userId], 118 | ); 119 | 120 | return rows[0].count; 121 | }; 122 | 123 | const getReportedPostByUser = async (client, userId, communityPostId) => { 124 | const { rows } = await client.query( 125 | ` 126 | SELECT 1 127 | FROM community_post_report_user cpru 128 | WHERE cpru.report_user_id = $1 AND cpru.community_post_id = $2 129 | `, 130 | [userId, communityPostId], 131 | ); 132 | 133 | return rows[0]; 134 | }; 135 | 136 | const getCommunityCategoryPostsCount = async (client, userId, communityCategoryId) => { 137 | const { rows } = await client.query( 138 | ` 139 | SELECT COUNT(*)::int 140 | FROM community_post cp 141 | JOIN community_category_post ccp ON cp.id = ccp.community_post_id 142 | LEFT JOIN community_post_report_user cpru ON cp.id = cpru.community_post_id AND cpru.report_user_id = $1 143 | WHERE cp.is_deleted = FALSE AND ccp.community_category_id = $2 AND cpru.id IS NULL 144 | `, 145 | [userId, communityCategoryId], 146 | ); 147 | 148 | return rows[0].count; 149 | }; 150 | 151 | const getCommunityCategoryPostsById = async ( 152 | client, 153 | userId, 154 | communityCategoryId, 155 | limit, 156 | offset, 157 | ) => { 158 | const { rows } = await client.query( 159 | ` 160 | SELECT cp.id, u.nickname, cp.title, cp.body, cp.content_url, cp.content_title, cp.content_description, cp.thumbnail_url, cp.created_at, 161 | CASE WHEN cp.user_id = $1 THEN TRUE ELSE FALSE END as is_author 162 | FROM community_post cp 163 | JOIN "user" u ON cp.user_id = u.id 164 | JOIN community_category_post ccp ON cp.id = ccp.community_post_id 165 | LEFT JOIN community_post_report_user cpru ON cp.id = cpru.community_post_id AND cpru.report_user_id = $1 166 | WHERE cp.is_deleted = FALSE AND ccp.community_category_id = $2 AND cpru.id IS NULL 167 | ORDER BY cp.created_at DESC, cp.id DESC 168 | LIMIT $3 OFFSET $4 169 | `, 170 | [userId, communityCategoryId, limit, offset], 171 | ); 172 | 173 | return convertSnakeToCamel.keysToCamel(rows); 174 | }; 175 | 176 | const reportCommunityPost = async (client, userId, communityPostId) => { 177 | const { rows: existingCommunityPosts } = await client.query( 178 | ` 179 | UPDATE community_post 180 | SET reported_count = reported_count + 1 181 | WHERE id = $1 182 | RETURNING * 183 | `, 184 | [communityPostId], 185 | ); 186 | if (!existingCommunityPosts[0]) return existingCommunityPosts[0]; 187 | 188 | const { title, user_id: postUserId } = existingCommunityPosts[0]; 189 | 190 | const { rows: communityPostReports } = await client.query( 191 | ` 192 | INSERT INTO community_post_report_user 193 | (report_user_id, community_post_id) 194 | VALUES 195 | ($1, $2) 196 | RETURNING * 197 | `, 198 | [userId, communityPostId], 199 | ); 200 | return { 201 | ...convertSnakeToCamel.keysToCamel(communityPostReports[0]), 202 | title, 203 | postUserId, 204 | }; 205 | }; 206 | 207 | const getCommunityPostById = async (client, communityPostId) => { 208 | const { rows } = await client.query( 209 | `SELECT * 210 | FROM community_post 211 | WHERE id = $1 AND is_deleted = FALSE 212 | `, 213 | [communityPostId], 214 | ); 215 | return convertSnakeToCamel.keysToCamel(rows[0]); 216 | }; 217 | 218 | const deleteCommunityPostById = async (client, communityPostId) => { 219 | const { rows } = await client.query( 220 | ` 221 | UPDATE community_post 222 | SET is_deleted = TRUE 223 | WHERE id = $1 224 | `, 225 | [communityPostId], 226 | ); 227 | return convertSnakeToCamel.keysToCamel(rows[0]); 228 | }; 229 | 230 | const deleteCommunityCategoryPostByPostId = async (client, communityPostId) => { 231 | const { rows } = await client.query( 232 | ` 233 | UPDATE community_category_post 234 | SET is_deleted = TRUE 235 | WHERE community_post_id = $1 236 | `, 237 | [communityPostId], 238 | ); 239 | return convertSnakeToCamel.keysToCamel(rows[0]); 240 | }; 241 | 242 | module.exports = { 243 | getCommunityPostDetail, 244 | getCommunityPosts, 245 | addCommunityPost, 246 | addCommunityCategoryPost, 247 | verifyExistCategories, 248 | isExistingCategory, 249 | getCommunityCategories, 250 | getCommunityPostsCount, 251 | getReportedPostByUser, 252 | getCommunityCategoryPostsCount, 253 | getCommunityCategoryPostsById, 254 | reportCommunityPost, 255 | getCommunityPostById, 256 | deleteCommunityPostById, 257 | deleteCommunityCategoryPostByPostId, 258 | }; 259 | -------------------------------------------------------------------------------- /functions/db/content.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const convertSnakeToCamel = require('../lib/convertSnakeToCamel'); 3 | 4 | const addContent = async (client, userId, title, description, image, url, isNotified, notificationTime) => { 5 | const { rows } = await client.query( 6 | ` 7 | INSERT INTO content 8 | (user_id, title, description, image, url, is_notified, notification_time) 9 | VALUES 10 | ($1, $2, $3, $4, $5, $6, $7) 11 | RETURNING * 12 | `, 13 | [userId, title, description, image, url, isNotified, notificationTime] 14 | ); 15 | return convertSnakeToCamel.keysToCamel(rows[0]); 16 | }; 17 | 18 | const toggleContent = async (client, contentId) => { 19 | const { rows } = await client.query( 20 | ` 21 | SELECT is_seen FROM content 22 | where id = $1 23 | `, 24 | [contentId] 25 | ); 26 | if (rows[0] === undefined) { 27 | // 특정 콘텐츠 id를 가진 콘텐츠가 존재하지 않을 때 28 | return convertSnakeToCamel.keysToCamel(rows[0]); 29 | } 30 | if (rows[0].is_seen === false) { 31 | // 특정 콘텐츠 id를 가진 콘텐츠가 존재할 때 32 | const { rows } = await client.query( 33 | ` 34 | UPDATE content 35 | SET is_seen = true, seen_at = now() 36 | WHERE id = $1 37 | RETURNING id, is_seen 38 | `, 39 | [contentId] 40 | ); 41 | return convertSnakeToCamel.keysToCamel(rows[0]); 42 | } 43 | else { 44 | const { rows } = await client.query( 45 | ` 46 | UPDATE content 47 | SET is_seen = false, seen_at = null 48 | WHERE id = $1 49 | RETURNING id, is_seen 50 | `, 51 | [contentId] 52 | ); 53 | return convertSnakeToCamel.keysToCamel(rows[0]); 54 | } 55 | }; 56 | 57 | const getContentsByFilter = async (client, userId, filter) => { 58 | if (filter === "reverse") { 59 | // API 로직에서 reverse 할 것이므로 created_at 기준으로 정렬한다. 60 | filter = "created_at"; 61 | } 62 | const { rows } = await client.query( 63 | ` 64 | SELECT c.id, c.title, c.image, c.description, c.url, c.is_seen, c.is_notified, c.notification_time, c.created_at, c.seen_at 65 | FROM content c 66 | WHERE c.user_id = $1 AND c.is_deleted = FALSE 67 | ORDER BY ${filter} DESC 68 | `, 69 | [userId] 70 | ); 71 | return convertSnakeToCamel.keysToCamel(rows); 72 | }; 73 | 74 | const getContentsByFilterAndNotified = async (client, userId, option, filter) => { 75 | if (filter === "reverse") { 76 | // API 로직에서 reverse 할 것이므로 created_at 기준으로 정렬한다. 77 | filter = "created_at"; 78 | } 79 | const { rows } = await client.query( 80 | ` 81 | SELECT c.id, c.title, c.image, c.description, c.url, c.is_seen, c.is_notified, c.notification_time, c.created_at, c.seen_at 82 | FROM content c 83 | WHERE c.user_id = $1 AND c.is_deleted = FALSE AND c.is_notified = ${option} AND c.notification_time > NOW() 84 | ORDER BY ${filter} DESC 85 | `, 86 | [userId] 87 | ); 88 | return convertSnakeToCamel.keysToCamel(rows); 89 | }; 90 | 91 | const getContentsByFilterAndSeen = async (client, userId, option, filter) => { 92 | if (filter === "reverse") { 93 | // API 로직에서 reverse 할 것이므로 createdAt 기준으로 정렬한다. 94 | filter = "created_at"; 95 | } 96 | const { rows } = await client.query( 97 | ` 98 | SELECT c.id, c.title, c.image, c.description, c.url, c.is_seen, c.is_notified, c.notification_time, c.created_at, c.seen_at 99 | FROM content c 100 | WHERE c.user_id = $1 AND c.is_deleted = FALSE AND c.is_seen = ${option} 101 | ORDER BY ${filter} DESC 102 | `, 103 | [userId] 104 | ); 105 | return convertSnakeToCamel.keysToCamel(rows); 106 | }; 107 | 108 | const searchContent = async (client, userId, keyword) => { 109 | const searchKeyword = '%' + keyword + '%'; 110 | const { rows } = await client.query( 111 | ` 112 | SELECT c.id, c.title, c.description, c.image, c.url, c.is_seen, c.is_notified, c.notification_time, c.created_at 113 | FROM content c 114 | WHERE c.user_id = $1 AND c.is_deleted = FALSE AND c.title ilike $2 115 | ORDER BY created_at DESC 116 | `, 117 | [userId, searchKeyword] 118 | ); 119 | return convertSnakeToCamel.keysToCamel(rows); 120 | }; 121 | 122 | const updateContentIsDeleted = async (client, categoryId, userId) => { 123 | const { rows } = await client.query( 124 | ` 125 | UPDATE content 126 | SET is_deleted = true, edited_at = now() 127 | FROM ( 128 | SELECT ca_content.content_id , COUNT(*) AS category_count 129 | FROM ( 130 | SELECT category_id, cc.content_id 131 | FROM category_content cc, ( 132 | SELECT cc.content_id 133 | FROM category_content cc 134 | WHERE cc.category_id = $1 135 | ) AS sub_content_id 136 | WHERE cc.content_id = sub_content_id.content_id ) AS ca_content 137 | GROUP BY ca_content.content_id 138 | HAVING COUNT(ca_content.content_id) > 0 ) AS count_content 139 | WHERE count_content.category_count <= 1 AND content.id = count_content.content_id AND user_id = $2 140 | `, 141 | [categoryId, userId] 142 | ); 143 | return convertSnakeToCamel.keysToCamel(rows[0]); 144 | }; 145 | 146 | const getRecentContents = async (client, userId) => { 147 | const { rows } = await client.query( 148 | ` 149 | SELECT c.id, c.title, c.description, c.image, c.url, c.is_seen, c.is_notified, c.notification_time, c.created_at 150 | FROM content c 151 | WHERE c.user_id = $1 AND c.is_deleted = FALSE 152 | ORDER BY created_at DESC 153 | LIMIT 20 154 | `, 155 | [userId] 156 | ); 157 | return convertSnakeToCamel.keysToCamel(rows); 158 | }; 159 | 160 | const getUnseenContents = async (client, userId) => { 161 | const { rows } = await client.query( 162 | ` 163 | SELECT c.id, c.title, c.description, c.image, c.url, c.is_seen, c.is_notified, c.notification_time, c.created_at 164 | FROM content c 165 | WHERE c.user_id = $1 AND c.is_deleted = FALSE AND c.is_seen = FALSE 166 | ORDER BY created_at DESC 167 | `, 168 | [userId] 169 | ); 170 | return convertSnakeToCamel.keysToCamel(rows); 171 | }; 172 | 173 | const deleteContent = async (client, contentId, userId) => { 174 | const { rows } = await client.query( 175 | ` 176 | UPDATE content 177 | SET is_deleted = TRUE, is_notified = FALSE, notification_time = null 178 | WHERE id = $1 AND user_id = $2 179 | RETURNING * 180 | `, 181 | [contentId, userId] 182 | ); 183 | return convertSnakeToCamel.keysToCamel(rows[0]); 184 | }; 185 | 186 | const renameContent = async (client, contentId, newTitle) => { 187 | const { rows } = await client.query( 188 | ` 189 | UPDATE content 190 | SET title = $2, edited_at = now() 191 | WHERE id = $1 AND is_deleted = FALSE 192 | RETURNING * 193 | `, 194 | [contentId, newTitle] 195 | ); 196 | return convertSnakeToCamel.keysToCamel(rows[0]); 197 | }; 198 | 199 | const updateContentNotification = async (client, contentId, notificationTime, isNotified) => { 200 | const { rows } = await client.query( 201 | ` 202 | UPDATE content 203 | SET notification_time = $2, edited_at = now(), is_notified = $3 204 | WHERE id = $1 AND is_deleted = FALSE 205 | RETURNING * 206 | `, 207 | [contentId, notificationTime, isNotified] 208 | ); 209 | return convertSnakeToCamel.keysToCamel(rows[0]); 210 | }; 211 | 212 | const getContent = async (client, userId, title, url) => { 213 | const { rows } = await client.query( 214 | ` 215 | SELECT user_id, title, url FROM content 216 | WHERE user_id = $1 AND title = $2 AND url = $3 217 | `, 218 | [userId, title, url] 219 | ); 220 | return convertSnakeToCamel.keysToCamel(rows[0]); 221 | } 222 | 223 | const getScheduledContentNotification = async (client, userId) => { 224 | const { rows } = await client.query( 225 | ` 226 | SELECT id, title, notification_time, url, image, description, created_at, is_seen FROM content 227 | WHERE user_id = $1 AND is_deleted = FALSE AND is_notified = TRUE AND notification_time > NOW() 228 | ORDER BY created_at DESC 229 | `, 230 | [userId] 231 | ); 232 | return convertSnakeToCamel.keysToCamel(rows); 233 | }; 234 | 235 | const getExpiredContentNotification = async (client, userId) => { 236 | const { rows } = await client.query( 237 | ` 238 | SELECT id, title, notification_time, url, image, description, created_at, is_seen FROM content 239 | WHERE user_id = $1 AND is_deleted = FALSE AND is_notified = TRUE AND notification_time <= NOW() 240 | ORDER BY created_at DESC 241 | `, 242 | [userId] 243 | ); 244 | return convertSnakeToCamel.keysToCamel(rows); 245 | }; 246 | 247 | const getContentById = async (client, contentId) => { 248 | const { rows } = await client.query( 249 | ` 250 | SELECT * 251 | FROM content 252 | WHERE id = $1 AND is_deleted = FALSE 253 | `, 254 | [contentId] 255 | ); 256 | return convertSnakeToCamel.keysToCamel(rows[0]); 257 | } 258 | 259 | module.exports = { addContent, toggleContent, getContentsByFilter, getContentsByFilterAndNotified, getContentsByFilterAndSeen, searchContent, updateContentIsDeleted, 260 | getRecentContents, getUnseenContents, deleteContent, renameContent, updateContentNotification, getContent, getScheduledContentNotification, getExpiredContentNotification, getContentById }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Havit-Server 2 | 3 | ### [기억하고 싶은 모든 콘텐츠를 내 손 안에, HAVIT](www.havit.app) 4 | 5 | [Playstore 에서 다운 받기](https://play.google.com/store/apps/details?id=org.sopt.havit&hl=ko&pli=1) 6 | [Appstore 에서 다운 받기](https://apps.apple.com/kr/app/havit-%EC%BD%98%ED%85%90%EC%B8%A0-%EC%95%84%EC%B9%B4%EC%9D%B4%EB%B9%99-%EC%95%B1-%ED%95%B4%EB%B9%97/id1607518014) 7 | 8 | > > > > > > > https://user-images.githubusercontent.com/55099365/150919289-52d35f31-c658-433a-8ffa-d84c8e6e85d8.mp4 9 | 10 | ![해빗표지2](https://user-images.githubusercontent.com/20807197/150502331-7122ba4e-5544-496b-baac-0cee2a21edc5.png) 11 | 12 | > 쉽고 빠르게 콘텐츠를 저장하고 카테고라이징하며 13 | > 지식을 놓치지 않도록 리마인드 해줄 수 있는 지식 아카이빙 앱 14 | > 해빗이 여러분의 성장과 함께 합니다. 15 | 16 | > SOPT 29th APPJAM 17 | > 18 | > 프로젝트 기간: 2022.01.02 ~ 2022.01.22 19 | 20 | ### ✅ 서비스 핵심 기능 21 | 22 | #### 1. Saving Process 23 | 24 | > 나에게 유용한 콘텐츠를 낮은 뎁스로 간단하게 저장할 수 있습니다. 25 | > iOS의 Share Extension, Android의 Intent Filter를 사용하여 홈 화면으로 나가서 앱을 키지 않아도, 콘텐츠를 보다가 사용자가 원하는 카테고리에 저장할 수 있습니다. 26 | 27 | #### 2. Category 28 | 29 | > 사용자가 카테고리를 직접 생성하며, 콘텐츠를 원하는대로 카테고라이징 할 수 있습니다. 카테고리는 여러 분야에서 적용 가능한 15개의 3D 아이콘을 제공합니다. 30 | 31 | #### 3. Contents 32 | 33 | > 사용자가 저장한 콘텐츠를 잊지 않도록 도와줍니다. 저장 과정에서 기억하기 쉬운 제목으로 수정 가능하고, 저장한 콘텐츠는 직접 지정한 시간에 알림 받을 수 있습니다. 34 | 35 | ### 📋 IA 36 | 37 | ![image](https://user-images.githubusercontent.com/20807197/148189262-1dec5ee4-e543-4822-b930-e796ef405863.png) 38 | 39 | ### ⚙️ Server Architecture 40 | 41 | 해빗앱서버아키텍쳐 42 | 43 | ### 🛠 Development Environment 44 | 45 | 46 | 47 | ### 📁 Foldering 48 | 49 | ``` 50 | 51 | 📁 functions _ 52 | |_ 📁 api _ 53 | | |_ 📋 index.js 54 | | |_ 📁 routes _ 55 | | |_ 📋 index.js 56 | | |_ 📁 user 57 | | |_ 📁 content 58 | | |_ 📁 category 59 | | |_ 📁 recommendation 60 | | 61 | |_ 📁 constants _ 62 | | |_ 📋responseMessage.js 63 | | |_ 📋 statusCode.js 64 | | 65 | |_ 📁 lib _ 66 | | |_ 📋 util.js 67 | | |_ 📋 convertSnakeToCamel.js 68 | | |_ 📋 jwtHandlers.js 69 | | 70 | |_ 📁 config _ 71 | | |_ 📋 dbConfig.js 72 | | 73 | | 74 | |_ 📁 middlewares _ 75 | | |_ 📋 auth.js 76 | | |_ 📋 slackAPI.js 77 | | 78 | | 79 | |_ 📁 db _ 80 | |_ 📋 index.js 81 | |_ 📋 db.js 82 | |_ 📋 user.js 83 | |_ 📋 category.js 84 | |_ 📋 content.js 85 | |_ 📋 categoryContent.js 86 | |_ 📋 recommendation.js 87 | 88 | ``` 89 | 90 | ### 📌 Dependencies Module 91 | 92 | ```json 93 | { 94 | "dependencies": { 95 | "axios": "^0.24.0", 96 | "busboy": "^0.3.1", 97 | "cookie-parser": "^1.4.6", 98 | "cors": "^2.8.5", 99 | "dayjs": "^1.10.7", 100 | "dotenv": "^10.0.0", 101 | "eslint-config-prettier": "^8.3.0", 102 | "express": "^4.17.2", 103 | "firebase-admin": "^9.8.0", 104 | "firebase-functions": "^3.14.1", 105 | "helmet": "^5.0.1", 106 | "hpp": "^0.2.3", 107 | "jsonwebtoken": "^8.5.1", 108 | "lodash": "^4.17.21", 109 | "open-graph-scraper": "^4.11.0", 110 | "pg": "^8.7.1" 111 | }, 112 | "devDependencies": { 113 | "chai": "^4.3.4", 114 | "eslint": "^7.6.0", 115 | "eslint-config-google": "^0.14.0", 116 | "firebase-functions-test": "^0.2.0", 117 | "mocha": "^9.1.4", 118 | "supertest": "^6.2.2" 119 | } 120 | } 121 | ``` 122 | 123 | ### 📌 담당 API 및 구현 진척도 124 | 125 | | 기능명 | 담당자 | 완료 여부 | 126 | | :-------------------------: | :------: | :------------: | 127 | | 카카오 로그인 | `주효식` | 앱잼 내 구현 X | 128 | | 마이페이지 조회 | `주효식` | ✅ | 129 | | 스크랩 | `주효식` | ✅ | 130 | | 콘텐츠 생성 | `주효식` | ✅ | 131 | | 콘텐츠 조회 여부 토글 | `주효식` | ✅ | 132 | | 전체 콘텐츠 조회 | `주효식` | ✅ | 133 | | 전체 콘텐츠 검색 | `주효식` | ✅ | 134 | | 콘텐츠 카테고리 이동 | `주효식` | ✅ | 135 | | 최근 저장 콘텐츠 조회 | `주효식` | ✅ | 136 | | 봐야 하는 콘텐츠 조회 | `주효식` | ✅ | 137 | | 콘텐츠 삭제 | `주효식` | ✅ | 138 | | 콘텐츠 제목 변경 | `주효식` | ✅ | 139 | | 카테고리 아이콘 이미지 조회 | `주효식` | ✅ | 140 | | 카테고리 순서 변경 | `주효식` | ✅ | 141 | | 추천 사이트 조회 | `채정아` | ✅ | 142 | | 카테고리 전체 조회 | `채정아` | ✅ | 143 | | 카테고리 생성 | `채정아` | ✅ | 144 | | 카테고리 수정 | `채정아` | ✅ | 145 | | 카테고리 삭제 | `채정아` | ✅ | 146 | | 카테고리 별 콘텐츠 조회 | `채정아` | ✅ | 147 | | 카테고리 이름 조회 | `채정아` | ✅ | 148 | | 카테고리 별 콘텐츠 검색 | `채정아` | 앱잼 내 구현 X | 149 | | 알림 전체 조회 | `채정아` | 앱잼 내 구현 X | 150 | 151 | > FCM-Push-Server 152 | > [Gihub Link](https://github.com/TeamHavit/Havit-Push-Server) 153 | 154 | | 기능명 | 담당자 | 완료 여부 | 155 | | :-------: | :------: | :-------: | 156 | | 유저 등록 | `채정아` | ✅ | 157 | | 알림 생성 | `채정아` | ✅ | 158 | | 알림 수정 | `채정아` | ✅ | 159 | 160 | ### 📌 Mocha API 유닛 테스트 161 | 162 | [결과 보고서](https://skitter-sloth-be4.notion.site/Mocha-API-7069530fb39a4293b11cab3ca77fe0ec) 163 | 164 | ### 📌 Branch Strategy 165 | 166 |
167 | Git Workflow 168 |
169 | 170 | ``` 171 | 1. local - feature에서 각자 기능 작업 172 | 2. 작업 완료 후 local - develop (ex. jobchae) 에 PR 후 Merge 173 | 3. 이후 remote - develop 으로 PR 174 | 4. 코드 리뷰 후 Confirm 받고 Merge 175 | 5. remote - develop 에 Merge 될 때 마다 모든 팀원 remote - develop pull 받아 최신 상태 유지 176 | ``` 177 | 178 |
179 |
180 | 181 | | Branch Name | 설명 | 182 | | :------------------: | :-----------------------: | 183 | | main | 초기 세팅 존재 | 184 | | develop | 로컬 develop merge 브랜치 | 185 | | philip | 효식 로컬 develop 브랜치 | 186 | | jobchae | 정아 로컬 develop 브랜치 | 187 | | localdevelop\_#issue | 각자 기능 추가 브랜치 | 188 | 189 | ### 📌 Commit Convention 190 | 191 | ##### [TAG] 메시지 192 | 193 | | 태그 이름 | 설명 | 194 | | :--------: | :---------------------------------------------------------------: | 195 | | [CHORE] | 코드 수정, 내부 파일 수정 | 196 | | [FEAT] | 새로운 기능 구현 | 197 | | [ADD] | FEAT 이외의 부수적인 코드 추가, 라이브러리 추가, 새로운 파일 생성 | 198 | | [HOTFIX] | issue나 QA에서 급한 버그 수정에 사용 | 199 | | [FIX] | 버그, 오류 해결 | 200 | | [DEL] | 쓸모 없는 코드 삭제 | 201 | | [DOCS] | README나 WIKI 등의 문서 개정 | 202 | | [CORRECT] | 주로 문법의 오류나 타입의 변경, 이름 변경에 사용 | 203 | | [MOVE] | 프로젝트 내 파일이나 코드의 이동 | 204 | | [RENAME] | 파일 이름 변경이 있을 때 사용 | 205 | | [IMPROVE] | 향상이 있을 때 사용 | 206 | | [REFACTOR] | 전면 수정이 있을 때 사용 | 207 | 208 | ### 📌 Coding Convention 209 | 210 |
211 | 변수명 212 |
213 | 214 | 215 | 1. Camel Case 사용 216 | - lower Camel Case 217 | 2. 함수의 경우 동사+명사 사용 218 | - ex) getInformation() 219 | 3. flag로 사용 되는 변수는 조동사 + flag 종류로 구성 220 | - ex) isNum 221 | 4. 약어는 되도록 사용하지 않는다. 222 | - 부득이하게 약어가 필요하다고 판단되는 경우 팀원과 상의를 거친다. 223 | 224 |
225 |
226 | 227 |
228 | 주석 229 |
230 | 231 | 1. 한줄 주석은 // 를 사용한다. 232 | 233 | ```javascript 234 | // 한줄 주석일 때 235 | /** 236 | * 여러줄 237 | * 주석일 때 238 | */ 239 | ``` 240 | 241 | 2. 함수에 대한 주석 242 | 243 | ```javascript 244 | /** 245 | * api get /travel/:groupNumber 246 | * 그룹 여행 정보 가져오기 247 | ``` 248 | 249 | 3. Bracket 사용 시 내부에 주석을 작성한다. 250 | 251 | ```javascript 252 | if (a === 5) { 253 | // 주석 254 | } 255 | ``` 256 | 257 |
258 |
259 | 260 |
261 | Bracket 262 |
263 | 264 | 1. 한줄 if 문은 여러 줄로 작성한다. 265 | 266 | ```javascript 267 | // 한줄 if 문 - 여러 줄로 작성 268 | if (trigger) { 269 | return; 270 | } 271 | ``` 272 | 273 | 2. 괄호는 한칸 띄우고 사용한다. 274 | 275 | ```javascript 276 | // 괄호 사용 한칸 띄우고 사용한다. 277 | if (left === true) { 278 | return; 279 | } 280 | ``` 281 | 282 | 3. Bracket 양쪽 사이를 띄어서 사용한다. 283 | 284 | ```javascript 285 | // 띄어쓰기 286 | if (a === 5) { 287 | // 양쪽 사이로 띄어쓰기 288 | return; 289 | } 290 | ``` 291 | 292 |
293 |
294 | 295 |
296 | 비동기 함수의 사용 297 |
298 | 299 | 1. async, await 함수 사용을 지향한다. 300 | 2. Promise 사용은 지양한다. 301 | 3. 다만 로직을 짜는 데 있어 promise를 불가피하게 사용할 경우, 주석으로 표시하고 commit에 그 이유를 작성한다. 302 | 303 |
304 |
305 | 306 | ### 👩🏻‍💻 Developers 307 | 308 | | 주효식 | 채정아 | 309 | | :----------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------: | 310 | | | | 311 | | [HYOSITIVE](https://github.com/HYOSITIVE) | [jokj624](https://github.com/jokj624) | 312 | -------------------------------------------------------------------------------- /test/content.spec.js: -------------------------------------------------------------------------------- 1 | const app = require('../functions/api/index'); 2 | const request = require('supertest'); 3 | const { expect } = require('chai'); 4 | const dotenv = require('dotenv'); 5 | dotenv.config(); 6 | 7 | describe('GET /content/scrap?link=www.naver.com', () => { 8 | it('콘텐츠 스크랩 성공', done => { 9 | request(app) 10 | .get('/content/scrap') 11 | .set('Content-Type', 'application/json') 12 | .set('x-auth-token', process.env.JWT_TOKEN) 13 | .query({ link: 'www.naver.com' }) 14 | .expect(200) 15 | .expect('Content-Type', /json/) 16 | .then(res => { 17 | expect(res.body.data.ogTitle).to.equal('네이버'); 18 | done(); 19 | }) 20 | .catch(err => { 21 | console.error("######Error >>", err); 22 | done(err); 23 | }) 24 | }); 25 | it('필요한 값 없음', done => { 26 | request(app) 27 | .get('/content/scrap') 28 | .set('Content-Type', 'application/json') 29 | .set('x-auth-token', process.env.JWT_TOKEN) 30 | .expect(400) 31 | .then(res => { 32 | done(); 33 | }) 34 | .catch(err => { 35 | console.error("######Error >>", err); 36 | done(err); 37 | }) 38 | }); 39 | }); 40 | 41 | describe('POST /content', () => { 42 | it('콘텐츠 생성 성공', done => { 43 | request(app) 44 | .post('/content') 45 | .set('Content-Type', 'application/json') 46 | .set('x-auth-token', process.env.JWT_TOKEN) 47 | .send({ 48 | "title": "콘텐츠 생성 테스트", 49 | "description": "클라이언트 분들 화이팅팅", 50 | "image": "https://img1.daumcdn.net/thumb/R800x0/?scode=mtistory2&fname=https%3A%2F%2Ft1.daumcdn.net%2Fcfile%2Ftistory%2F9994494C5C807AE00F", 51 | "url": "https://coding-factory.tistory.com/329", 52 | "isNotified": true, 53 | "notificationTime": "2022-01-23 03:12", 54 | "categoryIds": [6, 9] 55 | }) 56 | .expect(201) 57 | .expect('Content-Type', /json/) 58 | .then(res => { 59 | done(); 60 | }) 61 | .catch(err => { 62 | console.error("######Error >>", err); 63 | done(err); 64 | }) 65 | }); 66 | it('필요한 값 없음', done => { 67 | request(app) 68 | .post('/content') 69 | .set('Content-Type', 'application/json') 70 | .set('x-auth-token', process.env.JWT_TOKEN) 71 | .expect(400) 72 | .then(res => { 73 | done(); 74 | }) 75 | .catch(err => { 76 | console.error("######Error >>", err); 77 | done(err); 78 | }) 79 | }); 80 | it('존재하지 않는 카테고리', done => { 81 | request(app) 82 | .post('/content') 83 | .set('Content-Type', 'application/json') 84 | .set('x-auth-token', process.env.JWT_TOKEN) 85 | .send({ 86 | "title": "테스트입니다", 87 | "description": "서버 api 테스트", 88 | "image": "https://img1.daumcdn.net/thumb/R800x0/?scode=mtistory2&fname=https%3A%2F%2Ft1.daumcdn.net%2Fcfile%2Ftistory%2F9994494C5C807AE00F", 89 | "url": "https://coding-factory.tistory.com/329", 90 | "isNotified": true, 91 | "notificationTime": "2022-01-23 03:12", 92 | "categoryIds": [1, 14] 93 | }) 94 | .expect(404) 95 | .then(res => { 96 | done(); 97 | }) 98 | .catch(err => { 99 | console.error("######Error >>", err); 100 | done(err); 101 | }) 102 | }); 103 | }); 104 | 105 | describe('PATCH /content/check', () => { 106 | it('콘텐츠 조회 토글 체크 성공', done => { 107 | request(app) 108 | .patch('/content/check') 109 | .set('Content-Type', 'application/json') 110 | .set('x-auth-token', process.env.JWT_TOKEN) 111 | .send({ 112 | "contentId": 10 113 | }) 114 | .expect(200) 115 | .expect('Content-Type', /json/) 116 | .then(res => { 117 | expect(res.body.data.id).to.equal(10); 118 | expect(res.body.data.isSeen).to.equal(true); 119 | done(); 120 | }) 121 | .catch(err => { 122 | console.error("######Error >>", err); 123 | done(err); 124 | }) 125 | }); 126 | it('필요한 값 없음', done => { 127 | request(app) 128 | .patch('/content/check') 129 | .set('Content-Type', 'application/json') 130 | .set('x-auth-token', process.env.JWT_TOKEN) 131 | .expect(400) 132 | .then(res => { 133 | done(); 134 | }) 135 | .catch(err => { 136 | console.error("######Error >>", err); 137 | done(err); 138 | }) 139 | }); 140 | }); 141 | 142 | describe('GET /content', () => { 143 | it('콘텐츠 전체 조회 성공 시', done => { 144 | request(app) 145 | .get('/content') 146 | .set('Content-Type', 'application/json') 147 | .set('x-auth-token', process.env.JWT_TOKEN) 148 | .expect(200) 149 | .expect('Content-Type', /json/) 150 | .then(res => { 151 | done(); 152 | }) 153 | .catch(err => { 154 | console.error("######Error >>", err); 155 | done(err); 156 | }) 157 | }); 158 | }); 159 | 160 | describe('GET /content/search?keyword=요리', () => { 161 | it('전체 콘텐츠 키워드 검색 성공 시', done => { 162 | request(app) 163 | .get('/content/search') 164 | .set('Content-Type', 'application/json') 165 | .set('x-auth-token', process.env.JWT_TOKEN) 166 | .query({ keyword: '요리' }) 167 | .expect(200) 168 | .expect('Content-Type', /json/) 169 | .then(res => { 170 | done(); 171 | }) 172 | .catch(err => { 173 | console.error("######Error >>", err); 174 | done(err); 175 | }) 176 | }); 177 | it('필요한 값 없음', done => { 178 | request(app) 179 | .get('/content/search') 180 | .set('Content-Type', 'application/json') 181 | .set('x-auth-token', process.env.JWT_TOKEN) 182 | .expect(400) 183 | .expect('Content-Type', /json/) 184 | .then(res => { 185 | done(); 186 | }) 187 | .catch(err => { 188 | console.error("######Error >>", err); 189 | done(err); 190 | }) 191 | }); 192 | }); 193 | 194 | describe('PATCH /content/category', () => { 195 | it('콘텐츠 카테고리 변경', done => { 196 | request(app) 197 | .patch('/content/category') 198 | .set('Content-Type', 'application/json') 199 | .set('x-auth-token', process.env.JWT_TOKEN) 200 | .send({ 201 | "contentId": 10, 202 | "newCategoryIds": [7, 9] 203 | }) 204 | .expect(200) 205 | .expect('Content-Type', /json/) 206 | .then(res => { 207 | done(); 208 | }) 209 | .catch(err => { 210 | console.error("######Error >>", err); 211 | done(err); 212 | }) 213 | }); 214 | it('필요한 값 없음', done => { 215 | request(app) 216 | .patch('/content/category') 217 | .set('Content-Type', 'application/json') 218 | .set('x-auth-token', process.env.JWT_TOKEN) 219 | .expect(400) 220 | .send({ 221 | "contentId": 10, 222 | "newCategoryIds": [] 223 | }) 224 | .then(res => { 225 | done(); 226 | }) 227 | .catch(err => { 228 | console.error("######Error >>", err); 229 | done(err); 230 | }) 231 | }); 232 | it('존재하지 않는 카테고리', done => { 233 | request(app) 234 | .patch('/content/category') 235 | .set('Content-Type', 'application/json') 236 | .set('x-auth-token', process.env.JWT_TOKEN) 237 | .send({ 238 | "contentId": 10, 239 | "newCategoryIds": [7, 8] 240 | }) 241 | .expect(404) 242 | .then(res => { 243 | done(); 244 | }) 245 | .catch(err => { 246 | console.error("######Error >>", err); 247 | done(err); 248 | }) 249 | }); 250 | }); 251 | 252 | describe('PATCH /content/notification/:contentId', () => { 253 | it('콘텐츠 알림 시각 변경', done => { 254 | request(app) 255 | .patch('/content/notification/7') 256 | .set('Content-Type', 'application/json') 257 | .set('x-auth-token', process.env.JWT_TOKEN) 258 | .send({ 259 | "notificationTime": "2022-03-01 14:20" 260 | }) 261 | .expect(200) 262 | .expect('Content-Type', /json/) 263 | .then(res => { 264 | done(); 265 | }) 266 | .catch(err => { 267 | console.error("######Error >>", err); 268 | done(err); 269 | }) 270 | }); 271 | it('필요한 값 없음', done => { 272 | request(app) 273 | .patch('/content/notification/7') 274 | .set('Content-Type', 'application/json') 275 | .set('x-auth-token', process.env.JWT_TOKEN) 276 | .expect(400) 277 | .then(res => { 278 | done(); 279 | }) 280 | .catch(err => { 281 | console.error("######Error >>", err); 282 | done(err); 283 | }) 284 | }); 285 | it('존재하지 않는 컨텐츠', done => { 286 | request(app) 287 | .patch('/content/notification/40') 288 | .set('Content-Type', 'application/json') 289 | .set('x-auth-token', process.env.JWT_TOKEN) 290 | .send({ 291 | "notificationTime": "2022-02-03 13:20" 292 | }) 293 | .expect(404) 294 | .then(res => { 295 | done(); 296 | }) 297 | .catch(err => { 298 | console.error("######Error >>", err); 299 | done(err); 300 | }) 301 | }); 302 | }); 303 | 304 | describe('GET /content/recent', () => { 305 | it('최근 저장 콘텐츠 조회', done => { 306 | request(app) 307 | .get('/content/recent') 308 | .set('Content-Type', 'application/json') 309 | .set('x-auth-token', process.env.JWT_TOKEN) 310 | .expect(200) 311 | .expect('Content-Type', /json/) 312 | .then(res => { 313 | done(); 314 | }) 315 | .catch(err => { 316 | console.error("######Error >>", err); 317 | done(err); 318 | }) 319 | }); 320 | }); 321 | 322 | describe('DELETE /content/:contentId', () => { 323 | it('콘텐츠 삭제', done => { 324 | request(app) 325 | .delete('/content/10') 326 | .set('Content-Type', 'application/json') 327 | .set('x-auth-token', process.env.JWT_TOKEN) 328 | .expect(200) 329 | .expect('Content-Type', /json/) 330 | .then(res => { 331 | done(); 332 | }) 333 | .catch(err => { 334 | console.error("######Error >>", err); 335 | done(err); 336 | }) 337 | }); 338 | it('존재하지 않는 콘텐츠', done => { 339 | request(app) 340 | .delete('/content/50') 341 | .set('Content-Type', 'application/json') 342 | .set('x-auth-token', process.env.JWT_TOKEN) 343 | .expect(404) 344 | .expect('Content-Type', /json/) 345 | .then(res => { 346 | done(); 347 | }) 348 | .catch(err => { 349 | console.error("######Error >>", err); 350 | done(err); 351 | }) 352 | }); 353 | }); 354 | 355 | describe('PATCH /content/title/:contentId', () => { 356 | it('콘텐츠 제목 변경', done => { 357 | request(app) 358 | .patch('/content/title/7') 359 | .set('Content-Type', 'application/json') 360 | .set('x-auth-token', process.env.JWT_TOKEN) 361 | .send({ "newTitle": "알고리즘 만점 기원" }) 362 | .expect(200) 363 | .expect('Content-Type', /json/) 364 | .then(res => { 365 | done(); 366 | }) 367 | .catch(err => { 368 | console.error("######Error >>", err); 369 | done(err); 370 | }) 371 | }); 372 | it('필요한 값 없음', done => { 373 | request(app) 374 | .patch('/content/title/7') 375 | .set('Content-Type', 'application/json') 376 | .set('x-auth-token', process.env.JWT_TOKEN) 377 | .expect(400) 378 | .expect('Content-Type', /json/) 379 | .then(res => { 380 | done(); 381 | }) 382 | .catch(err => { 383 | console.error("######Error >>", err); 384 | done(err); 385 | }) 386 | }); 387 | it('존재하지 않는 콘텐츠', done => { 388 | request(app) 389 | .patch('/content/title/15') 390 | .set('Content-Type', 'application/json') 391 | .set('x-auth-token', process.env.JWT_TOKEN) 392 | .send({ "newTitle": "알고리즘 만점 기원" }) 393 | .expect(404) 394 | .expect('Content-Type', /json/) 395 | .then(res => { 396 | done(); 397 | }) 398 | .catch(err => { 399 | console.error("######Error >>", err); 400 | done(err); 401 | }) 402 | }); 403 | }); --------------------------------------------------------------------------------