├── 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 | 
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 | 
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 | });
--------------------------------------------------------------------------------