몽구스로 댓글 대댓글 구현하기

00_8_3·2021년 1월 3일
9

몽구스로 댓글 대댓글 구현하기

Schema

User Schema

const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const Post = require('./Posts');

const UserSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    trim: true,
  },
  googleId: {
    type: String,
    unique: true,
  },
  naverId: {
    type: String,
    unique: true,
  },
  password: {
    type: String,
    required: true,
    trim: true,
    minlength: 5,
  },
  name: {
    type: String,
    trim: true,
    required: true,
  },
  age: {
    type: Number,
    default: 0,
  },
  date: {
    type: Date,
    default: Date.now,
  },


UserSchema.virtual('posts', {
  ref: 'Post',
  localField: '_id',
  foreignField: 'userId',
});
UserSchema.virtual('comments', {
  ref: 'Comment',
  localField: '_id',
  foreignField: 'author',
});
UserSchema.set('toObject', { virtuals: true });
UserSchema.set('toJSON', { virtuals: true });


UserSchema.pre('remove', async function (next) {
  const user = this;
  try {
    await Post.deleteMany({ userId: user._id });
    next();
  } catch (e) {
    next();
  }
});




module.exports = mongoose.model('User', UserSchema);

Post Schema

const mongoose = require('mongoose');
const Comment = require('./Comments');

const postSchema = new mongoose.Schema({
  text: {
    type: String,
    required: true,
  },
  completed: { type: String, required: true, default: false },
  userId: {
    type: mongoose.Schema.Types.ObjectId,
    required: true,
    ref: 'User',
  },
  created_at: {
    type: Date,
    default: Date.now,
  },
  updated_at: {
    type: Date,
  },
});

postSchema.set('toObject', { virtuals: true });
postSchema.set('toJSON', { virtuals: true });

postSchema.virtual('comments', {
  ref: 'Comment',
  localField: '_id',
  foreignField: 'post',
});

postSchema.methods.createPost = function (text) {
  const post = new this({
    text: text,
  });
  return post.save();
};
postSchema.pre('remove', async function (next) {
  const post = this;
  try {
    await Comment.deleteMany({ post: post._id });
    next();
  } catch (e) {
    next();
  }
});

module.exports = mongoose.model('Post', postSchema);

comment Schema

const mongoose = require('mongoose');
const commentSchema = mongoose.Schema(
  {
    post: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Post',
      require: true,
    },
    author: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'User',
      required: true,
    },
    parentComment: { // 1
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Comment',
    },
    text: {
      type: String,
      required: true,
    },
    depth: {
      type: Number,
      default: 1,
    },
    isDeleted: { // 2
      type: Boolean,
      default: false,
    },
    createdAt: {
      type: Date,
      default: Date.now,
    },
    updatedAt: {
      type: Date,
    },
  },
  { toObject: { virtuals: true }, toJSON: { virtuals: true } },
);
commentSchema.virtual('comments', {
  ref: 'Comment',
  localField: '_id',
  foreignField: 'parentComment',
});

commentSchema
  .virtual('childComments')
  .get(function () {
    return this._childComments;
  })
  .set(function (v) {
    this._childComments = v;
  });

module.exports = mongoose.model('Comment', commentSchema);
  • 1
    대댓글은 다른 댓글에 달리게 되므로 댓글과 댓글간의 관계 형성이 필요합니다. 이처럼 자기 자신의 모델을 자신의 항목으로 가지는 것을 self referencing relationship이라고 합니다. 또한 댓글-대댓글은 동일한 모델이 상하관계를 가지게 되는데 이때 상위에 있는 것을 부모(parent) 라고 하고, 하위에 있는 것을 자식(child) 이라고 부릅니다. 그래서 parentComment라는 항목을 추가하여 대댓글인 경우 어느 댓글에 달린 댓글인지를 표시하였습니다. 대댓글이 아니고 게시물에 바로 달리는 댓글은 부모 댓글이 없으므로 required는 필요하지 않습니다.

  • 2
    게시물-댓글-대댓글-대댓글... 이런식의 구조로 형성될 텐데, 만약 중간 댓글이 완전히 삭제되어 버리면 하위 댓글들이 부모를 잃고 고아가 됩니다(실제로 고아(orphaned)라고 표현합니다). 이를 방지하기 위해 진짜로 DB에서 댓글 데이터를 지우는 것이아니라, isDeleted: true 로 표시해 웹사이트 상에는 표시되지 않게 합니다.

  • 3
    DB상에는 대댓글의 부모정보만 저장하지만, 웹사이트에 사용할 때는 부모로부터 자식들을 찾아 내려가는 것이 더 편리하기 때문에 자식 댓글들의 정보를 가지는 항목을 virtual 항목으로 추가하였습니다.

Routes

comments Route

const express  = require('express');
const router = express.Router();
const Comment = require('../models/Comment');
const Post = require('../models/Post');

// create
router.post('/', auth, checkPostId, function(req, res){ 
  var post = res.locals.post; // 1

  req.body.author = req.user._id; // 2
  req.body.post = post._id;       // 2

  Comment.create(req.body, function(err, comment){
    if(err){
      return console.error(err);  // req,body에 댓글의 내용이 들어있다.
    }
    return res.json({comment});
  });
});

module.exports = router;

// private functions
function checkPostId(req, res, next){ // 1
  Post.findOne({_id:req.query.postId},function(err, post){
    if(err) return res.json(err);

    res.locals.post = post; // 1
    next();
  });
}
  • 1
    미들웨어 checkPostId로 /comments?postId=postId같은 쿼리를 res.locals.post에 집어 넣는다.

res.locals란 req의 생명주기 동안만 유효하다.
참조 : https://stackoverflow.com/questions/35111143/express4-whats-the-difference-between-app-locals-res-locals-and-req-app-local

  • 2
    auth와 checkPostId로 미들웨어에서 얻은 데이터를 req.body에 넣어준다.

posts Route

// routes/posts.js
const express  = require('express');
const router = express.Router();
const Post = require('../models/Post');
const User = require('../models/User');
const Comment = require('../models/Comment'); 


  Promise.all([ // 1
      Post.findOne({_id:req.params.id}).populate({ path: 'author', select: 'username' }),
      Comment.find({post:req.params.id}).sort('createdAt').populate({ path: 'author', select: 'username' })
    ])
    .then(([post, comments]) => {
      return res.json({post, comments}); // 1
    })
    .catch((err) => {
      console.log('err: ', err);
      return res.json(err);
    });
});
  • 1
    하나의 post에 해당하는 post게시물과 댓글들을 모두 찾아 반환한다.

트리형태로 변환

// util.js
util.convertToTrees = function(array, idFieldName, parentIdFieldName, childrenFieldName){
  var cloned = array.slice();

  for(var i=cloned.length-1; i>-1; i--){
    var parentId = cloned[i][parentIdFieldName];

    if(parentId){
      var filtered = array.filter(function(elem){
        return elem[idFieldName].toString() == parentId.toString();
      });

      if(filtered.length){
        var parent = filtered[0];

        if(parent[childrenFieldName]){
          parent[childrenFieldName].push(cloned[i]);
        }
        else {
          parent[childrenFieldName] = [cloned[i]];
        }
      }
      cloned.splice(i,1);
    }
  }
  return cloned;
}

module.exports = util;

post Route 수정

// routes/post.js

// show
router.get('/:id', function(req, res){

  Promise.all([
      Post.findOne({_id:req.params.id}).populate({ path: 'author', select: 'username' }),
      Comment.find({post:req.params.id}).sort('createdAt').populate({ path: 'author', select: 'username' })
    ])
    .then(([post, comments]) => {
      const commentTrees = util.convertToTrees(comments, '_id','parentComment','childComments'); // 1
    	return res.json({post, commentTrees})
    })
    .catch((err) => {
      return res.json(err);
    });
});

0개의 댓글