OurBigBook logoOurBigBook Docs OurBigBook logoOurBigBook.comSite Source code
web/api/issues.js
const ourbigbook = require('ourbigbook')

const router = require('express').Router()

const auth = require('../auth')
const config = require('../front/config')
const { cant } = require('../front/cant')
const front = require('../front/js')
const routes = require('../front/routes')
const { convertIssue, convertComment } = require('../convert')
const lib = require('./lib')
const {
  ValidationError,
  checkMaxNewPerTimePeriod,
  getArticle,
  oneMinuteAgo,
  oneHourAgo,
  validateParam,
} = lib

function issueCommentEmailBody({
  body,
  childUrl,
  settingsUrl,
  whatChild,
  whatParent,
  whatParentUnsub,
  unsubThisUrl,
}) {
  const htmlArr = [`<p><a href="${childUrl}">${childUrl}</a></p>`]
  let textBody
  if (body) {
    htmlArr.push(`<p><pre>${ourbigbook.htmlEscapeContent(body)}<pre></p>`)
    textBody = `\n${body}`
  } else {
    textBody = ''
  }
  htmlArr.push(
    `<p>A new ${whatChild} has been created on a ${whatParent} that you follow</a>!</p>`,
    `<p>To unsubscribe from this ${whatParent} click the <a href="${unsubThisUrl}">unsubscribe button on the ${whatParentUnsub} page.</p>`,
    `<p>To unsubscribe from all ${config.appName} emails <a href="${settingsUrl}">change the email settings on your profile page</a>.</p>`,
  )
  return {
    html: htmlArr.join(''),
    text: `${childUrl}
${textBody}
A new ${whatChild} has been created on an ${whatParent} that you follow!

To unsubscribe from this ${whatParent} click the unsubscribe button on the ${whatParentUnsub} page: ${unsubThisUrl}

To unsubscribe from all ${config.appName} emails change the email settings on your profile page: ${settingsUrl}
`,
  }
}

// Get issues for an article.
// TODO: make article optional, generalize this more as a general find issues function.
router.get('/', auth.optional, async function(req, res, next) {
  try {
    const sequelize = req.app.get('sequelize')
    const { Article, User } = sequelize.models
    const opts = {
      includeIssues: true,
      includeIssuesOrder: lib.getOrder(req, {
        allowedSortsExtra: Article.ALLOWED_SORTS_EXTRA,
      }),
    }
    const number = lib.validateParam(req.query, 'number', {
      typecast: front.typecastInteger,
      validators: [front.isPositiveInteger],
      defaultValue: undefined,
    })
    if (number !== undefined) {
      opts.includeIssueNumber = number
    }
    const [article, loggedInUser] = await Promise.all([
      getArticle(req, res, opts),
      req.payload ? User.findByPk(req.payload.id) : null,
    ])
    return res.json({
      issues: await Promise.all(article.issues.map(issue => issue.toJson(loggedInUser)))
    })
  } catch(error) {
    next(error);
  }
})

function getIssueParams(req, res) {
  return {
    number: lib.validateParam(req.params, 'number', {
      typecast: front.typecastInteger,
      validators: [front.isPositiveInteger],
    }),
    slug: validateParam(req.query, 'id'),
  }
}

async function getIssue(req, res, options={}) {
  const { includeComments } = options
  const sequelize = req.app.get('sequelize')
  const { slug, number } = getIssueParams(req, res)
  const issue = await sequelize.models.Issue.getIssue({
    includeComments,
    includeArticle: true,
    number,
    order: lib.getOrder(req),
    sequelize,
    slug,
  })
  if (!issue) {
    throw new ValidationError(
      [`issue not found: article slug: "${slug}" issue number: ${number}`],
      404,
    )
  }
  return issue
}

// Create a new issue.
router.post('/', auth.required, async function(req, res, next) {
  try {
    const sequelize = req.app.get('sequelize')
    const slug = validateParam(req.query, 'id')
    const [
      article,
      issueCountByLoggedInUser,
      issueCountByLoggedInUserLastMinute,
      issueCountByLoggedInUserLastHour,
      lastIssue,
      loggedInUser
    ] = await Promise.all([
      getArticle(req, res),
      sequelize.models.Issue.count({ where: { authorId: req.payload.id } }),
      sequelize.models.Issue.count({ where: {
        authorId: req.payload.id,
        createdAt: { [sequelize.Sequelize.Op.gt]: oneMinuteAgo() }
      }}),
      sequelize.models.Issue.count({ where: {
        authorId: req.payload.id,
        createdAt: { [sequelize.Sequelize.Op.gt]: oneHourAgo() }
      }}),
      sequelize.models.Issue.findOne({
        order: [['number', 'DESC']],
        include: [{
          model: sequelize.models.Article,
          as: 'article',
          where: { slug },
        }]
      }),
      sequelize.models.User.findByPk(req.payload.id),
    ])
    const errs = []
    let err = front.hasReachedMaxItemCount(loggedInUser, issueCountByLoggedInUser, 'issues')
    if (err) { errs.push(err) }
    checkMaxNewPerTimePeriod({
      errs,
      loggedInUser,
      newCountLastHour: issueCountByLoggedInUserLastHour,
      newCountLastMinute: issueCountByLoggedInUserLastMinute,
      objectName: 'issue',
    })
    if (errs.length) { throw new ValidationError(errs, 403) }
    const body = lib.validateParam(req, 'body')
    const issueData = lib.validateParam(body, 'issue')
    const bodySource = lib.validateParam(issueData, 'bodySource', {
      validators: [ front.isString ],
      defaultValue: ''
    })
    lib.validateBodySize(loggedInUser, bodySource)
    const titleSource = lib.validateParam(issueData, 'titleSource', {
      validators: [front.isString, front.isTruthy]
    })
    const issue = await convertIssue({
      article,
      bodySource,
      number: lastIssue ? lastIssue.number + 1 : 1,
      sequelize,
      titleSource,
      user: loggedInUser
    })
    issue.author = loggedInUser
    const [issueJson, followers] = await Promise.all([
      issue.toJson(loggedInUser),
      article.getFollowers(),
    ])
    for (const follower of followers) {
      if (loggedInUser.id !== follower.id) {
        if (
          follower.id !== loggedInUser.id &&
          follower.emailNotifications
        ) {
          const emailBody = issueCommentEmailBody({
            body: issue.bodySource,
            childUrl: `${routes.host(req)}${routes.issue(article.slug, issue.number)}`,
            settingsUrl: `${routes.host(req)}${routes.userEdit(loggedInUser.username)}`,
            whatParent: 'article',
            whatParentUnsub: 'article discussions',
            whatChild: 'discussion',
            unsubThisUrl: `${routes.host(req)}${routes.articleIssues(article.slug)}`,
          })
          lib.sendEmail(Object.assign({
            to: follower.email,
            fromName: loggedInUser.displayName,
            subject: `[${article.slug}#${issue.number}] ${issue.titleSource}`,
          }, emailBody))
        }
      }
    }
    res.json({ issue: issueJson })
  } catch(error) {
    next(error);
  }
})

// Update issue.
router.put('/:number', auth.required, async function(req, res, next) {
  try {
    const sequelize = req.app.get('sequelize')
    const [issue, loggedInUser] = await Promise.all([
      getIssue(req, res),
      sequelize.models.User.findByPk(req.payload.id),
    ])
    const article = issue.article
    if (cant.editIssue(loggedInUser, issue.author.username)) {
      return res.sendStatus(403)
    }
    const body = lib.validateParam(req, 'body')
    const issueData = lib.validateParam(body, 'issue')
    const bodySource = lib.validateParam(issueData, 'bodySource', {
      validators: [front.isString],
      defaultValue: undefined,
    })
    if (bodySource !== undefined) {
      lib.validateBodySize(loggedInUser, bodySource)
    }
    const titleSource = lib.validateParam(issueData, 'titleSource', {
      validators: [front.isString, front.isTruthy],
      defaultValue: undefined,
    })
    const newIssue = await convertIssue({
      article,
      bodySource,
      issue,
      sequelize,
      titleSource,
      user: loggedInUser,
    })
    newIssue.author = loggedInUser
    res.json({ issue: await newIssue.toJson(loggedInUser) })
  } catch(error) {
    next(error);
  }
})

async function validateFollow(req, res, user, article, create) {
  if (!article) {
    throw new ValidationError(
      ['Article not found'],
      404,
    )
  }
  let msg
  if (create) {
    msg = cant.followArticle(user, article)
  } else {
    msg = cant.unfollowArticle(user, article)
  }
  if (msg) {
    throw new ValidationError([msg], 403)
  }
  if ((await user.hasFollowedIssue(article)) === create) {
    throw new ValidationError(
      [`User '${user.username}' ${create ? 'already follows' : 'does not follow'} issue '${article.number}'`],
      403,
    )
  }
}

// Follow an issue
router.post('/:number/follow', auth.required, async function(req, res, next) {
  try {
    const [article, loggedInUser] = await Promise.all([
      getIssue(req, res),
      req.app.get('sequelize').models.User.findByPk(req.payload.id),
    ])
    await validateFollow(req, res, loggedInUser, article, true)
    await loggedInUser.addIssueFollowSideEffects(article)
    const newArticle = await lib.getArticle(req, res)
    return res.json({ article: await newArticle.toJson(loggedInUser) })
  } catch(error) {
    next(error);
  }
})

// Unfollow an issue
router.delete('/:number/follow', auth.required, async function(req, res, next) {
  try {
    const [article, loggedInUser] = await Promise.all([
      getIssue(req, res),
      req.app.get('sequelize').models.User.findByPk(req.payload.id),
    ])
    await validateFollow(req, res, loggedInUser, article, false)
    await loggedInUser.removeIssueFollowSideEffects(article)
    const newArticle = await lib.getArticle(req, res)
    return res.json({ article: await newArticle.toJson(loggedInUser) })
  } catch(error) {
    next(error);
  }
})

async function validateLike(req, res, user, article, isLike) {
  if (!article) {
    throw new ValidationError(
      ['Article not found'],
      404,
    )
  }
  let msg
  if (isLike) {
    msg = cant.likeArticle(user, article)
  } else {
    msg = cant.unlikeArticle(user, article)
  }
  if (msg) {
    throw new ValidationError([msg], 403)
  }
  if ((await user.hasLikedIssue(article)) === isLike) {
    throw new ValidationError(
      [`User '${user.username}' ${isLike ? 'already likes' : 'does not like'} issue '${article.number}'`],
      403,
    )
  }
}

// Like an issue
router.post('/:number/like', auth.required, async function(req, res, next) {
  try {
    const sequelize = req.app.get('sequelize')
    await lib.likeObject({
      getObject: getIssue,
      joinModel: sequelize.models.UserLikeIssue,
      objectName: 'issue',
      req,
      res,
      validateLike,
    })
  } catch(error) {
    next(error);
  }
})

// Unlike an issue
router.delete('/:number/like', auth.required, async function(req, res, next) {
  try {
    const [article, loggedInUser] = await Promise.all([
      getIssue(req, res),
      req.app.get('sequelize').models.User.findByPk(req.payload.id),
    ])
    await validateLike(req, res, loggedInUser, article, false)
    await loggedInUser.removeIssueLikeSideEffects(article)
    const newArticle = await lib.getArticle(req, res)
    return res.json({ article: await newArticle.toJson(loggedInUser) })
  } catch(error) {
    next(error);
  }
})

// Get issues's comments.
router.get('/:number/comments', auth.optional, async function(req, res, next) {
  try {
    const [issue, loggedInUser] = await Promise.all([
      getIssue(req, res, { includeComments: true }),
      req.payload ? req.app.get('sequelize').models.User.findByPk(req.payload.id) : null,
    ])
    return res.json({
      comments: await Promise.all(issue.comments.map(comment => comment.toJson(loggedInUser)))
    })
  } catch(error) {
    next(error);
  }
})

// Create a new comment.
router.post('/:number/comments', auth.required, async function(req, res, next) {
  try {
    const { slug, number } = getIssueParams(req, res)
    const sequelize = req.app.get('sequelize')
    const [
      commentCountByLoggedInUser,
      commentCountByLoggedInUserLastMinute,
      commentCountByLoggedInUserLastHour,
      issue,
      lastComment,
      loggedInUser
    ] = await Promise.all([
      sequelize.models.Comment.count({ where: { authorId: req.payload.id } }),
      sequelize.models.Comment.count({ where: {
        authorId: req.payload.id,
        createdAt: { [sequelize.Sequelize.Op.gt]: oneMinuteAgo() }
      }}),
      sequelize.models.Comment.count({ where: {
        authorId: req.payload.id,
        createdAt: { [sequelize.Sequelize.Op.gt]: oneHourAgo() }
      }}),
      getIssue(req, res),
      sequelize.models.Comment.findOne({
        order: [['number', 'DESC']],
        include: [{
          model: sequelize.models.Issue,
          as: 'issue',
          where: { number },
          include: [{
            model: sequelize.models.Article,
            as: 'article',
            where: { slug },
          }],
        }]
      }),
      sequelize.models.User.findByPk(req.payload.id),
    ])

    const errs = []
    let err = front.hasReachedMaxItemCount(loggedInUser, commentCountByLoggedInUser, 'comments')
    if (err) { errs.push(err) }
    checkMaxNewPerTimePeriod({
      errs,
      loggedInUser,
      newCountLastHour: commentCountByLoggedInUserLastHour,
      newCountLastMinute: commentCountByLoggedInUserLastMinute,
      objectName: 'comment',
    })
    if (errs.length) { throw new ValidationError(errs, 403) }
    const body = lib.validateParam(req, 'body')
    const commentData = lib.validateParam(body, 'comment')
    const source = lib.validateParam(commentData, 'source', {
      validators: [front.isString],
    })
    lib.validateBodySize(loggedInUser, source)
    const comment = await convertComment({
      issue,
      number: lastComment ? lastComment.number + 1 : 1,
      sequelize,
      source,
      user: loggedInUser,
    })
    comment.author = loggedInUser
    const article = issue.article
    const [commentJson, followers] = await Promise.all([
      comment.toJson(loggedInUser),
      issue.getFollowers(),
    ])
    for (const follower of followers) {
      if (loggedInUser.id !== follower.id) {
        const whatParent = 'discussion'
        const emailBody = issueCommentEmailBody({
          body: comment.source,
          childUrl: `${routes.host(req)}${routes.issueComment(article.slug, issue.number, comment.number)}`,
          settingsUrl: `${routes.host(req)}${routes.userEdit(loggedInUser.username)}`,
          whatParent,
          whatParentUnsub: whatParent,
          whatChild: 'comment',
          unsubThisUrl: `${routes.host(req)}${routes.issueComments(article.slug, issue.number)}`,
        })
        if (
          follower.id !== loggedInUser.id &&
          follower.emailNotifications
        ) {
          lib.sendEmail(Object.assign({
            to: follower.email,
            fromName: loggedInUser.displayName,
            subject: `[${article.slug}#${issue.number}] ${issue.titleSource}`,
          }, emailBody))
        }
      }
    }
    res.json({ comment: commentJson })
  } catch(error) {
    next(error);
  }
})

async function getComment(req, res, options={}) {
  const sequelize = req.app.get('sequelize')
  const commentNumber = lib.validateParam(req.params, 'commentNumber', {
    typecast: front.typecastInteger,
    validators: [front.isPositiveInteger],
  })
  const issueNumber = lib.validateParam(req.params, 'issueNumber', {
    typecast: front.typecastInteger,
    validators: [front.isPositiveInteger],
  })
  const slug = validateParam(req.query, 'id')
  const comment = await sequelize.models.Comment.findOne({
    where: {
      number: commentNumber,
    },
    include: [
      {
        model: sequelize.models.Issue,
        as: 'issue',
        where: { number: issueNumber },
        required: true,
        include: [
          {
            model: sequelize.models.Article,
            as: 'article',
            where: { slug },
            required: true,
          }
        ],
      },
      {
        model: sequelize.models.User,
        as: 'author',
      },
    ],
  })
  if (!comment) {
    throw new ValidationError(
      [`comment not found: article slug: "${slug}" issue number: ${issueNumber} comment number: ${commentNumber}`],
      404,
    )
  }
  return comment
}

// Get a comment
router.get('/:issueNumber/comment/:commentNumber', auth.optional, async function(req, res, next) {
  try {
    const sequelize = req.app.get('sequelize')
    const [comment, loggedInUser] = await Promise.all([
      getComment(req, res, next),
      sequelize.models.User.findByPk(req.payload.id),
    ])
    return res.json(await comment.toJson(loggedInUser))
  } catch(error) {
    next(error);
  }
})

// Delete a comment
router.delete('/:issueNumber/comments/:commentNumber', auth.required, async function(req, res, next) {
  try {
    const sequelize = req.app.get('sequelize')
    const [comment, loggedInUser] = await Promise.all([
      getComment(req, res, next),
      sequelize.models.User.findByPk(req.payload.id),
    ])
    if (cant.deleteComment(loggedInUser, comment)) {
      res.sendStatus(403)
    } else {
      await comment.destroySideEffects()
      res.sendStatus(204)
    }
  } catch(error) {
    next(error);
  }
})

module.exports = router