codecombat/server/middleware/patchable.coffee
Scott Erickson be1c1323ac Patch fixes
* Partially revert GET /db/:collection/:handle/patches, as it returned limited results for versioned docs
* Fix POST /db/:collection/:handle/patch to:
   * normalize diffs based on latest doc version
   * handle empty deltas
   * not swallow thrown errors due to _.curry
   * Set 'target.original' correctly for versioned collections
2016-09-16 16:02:56 -07:00

170 lines
6.6 KiB
CoffeeScript

utils = require '../lib/utils'
errors = require '../commons/errors'
wrap = require 'co-express'
Promise = require 'bluebird'
Patch = require '../models/Patch'
User = require '../models/User'
mongoose = require 'mongoose'
database = require '../commons/database'
parse = require '../commons/parse'
slack = require '../slack'
{ isJustFillingTranslations } = require '../commons/deltas'
{ updateI18NCoverage } = require '../commons/i18n'
{ initNewVersion, saveNewVersion } = require '../middleware/versions'
{ notifyChangesMadeToDoc } = require '../commons/notify'
module.exports =
patches: (Model, options={}) -> wrap (req, res) ->
dbq = Patch.find()
dbq.limit(parse.getLimitFromReq(req))
dbq.skip(parse.getSkipFromReq(req))
dbq.select(parse.getProjectFromReq(req))
id = req.params.handle
if not database.isID(id)
# handle slug
doc = yield database.getDocFromHandle(req, Model, {_id: 1})
if not doc
throw new errors.NotFound('Patchable document not found')
id = (doc.get('original') or doc.id) + ''
query =
$or: [
{'target.original': id+''}
{'target.original': mongoose.Types.ObjectId(id)}
]
if req.query.status
query.status = req.query.status
if req.user and req.query.creator is req.user.id
query.creator = req.user._id
patches = yield dbq.find(query).sort('-created')
res.status(200).send(patches)
joinWatchers: (Model, options={}) -> wrap (req, res) ->
doc = yield database.getDocFromHandle(req, Model)
if not doc
throw new errors.NotFound('Document not found.')
if not database.hasAccessToDocument(req, doc, 'get')
throw new errors.Forbidden()
updateResult = yield doc.update({ $addToSet: { watchers: req.user.get('_id') }})
if updateResult.nModified
watchers = doc.get('watchers')
watchers.push(req.user.get('_id'))
doc.set('watchers', watchers)
res.status(200).send(doc)
leaveWatchers: (Model, options={}) -> wrap (req, res) ->
doc = yield database.getDocFromHandle(req, Model)
if not doc
throw new errors.NotFound('Document not found.')
updateResult = yield doc.update({ $pull: { watchers: req.user.get('_id') }})
if updateResult.nModified
watchers = doc.get('watchers')
watchers = _.filter watchers, (id) -> not id.equals(req.user._id)
doc.set('watchers', watchers)
res.status(200).send(doc)
postPatch: (Model, collectionName) -> wrap (req, res) ->
# handle either "POST /db/<collection>/:handle/patch" or "POST /db/patch" with target included in body
# Tried currying the function, but it didn't play nice with the generator function.
if req.params.handle
target = yield database.getDocFromHandle(req, Model, {getLatest:true})
else if req.body.target?.id
target = yield Model.findById(req.body.target.id)
if Model.schema.uses_coco_versions and target and not target.get('version.isLatestMajor')
original = target.get('original')
target = yield Model.findOne({original}).sort({ 'version.major': -1, 'version.minor': -1 })
if not target
throw new errors.NotFound('Target not found.')
# normalize the delta because in tests, changes to patches would sneak in and cause false positives
# TODO: Figure out a better system. Perhaps submit a series of paths? I18N Edit Views already use them for their rows.
originalDelta = req.body.delta
originalTarget = target.toObject()
# _.cloneDeep can't handle ObjectIds, and be careful with Dates, too.
newTargetAttrs = _.cloneDeep(target.toObject(), (value) ->
return value if value instanceof mongoose.Types.ObjectId
return value if value instanceof Date
return undefined
)
if _.isEmpty(originalDelta)
throw new errors.UnprocessableEntity('Delta given is empty.')
jsondiffpatch.patch(newTargetAttrs, originalDelta)
normalizedDelta = jsondiffpatch.diff(originalTarget, newTargetAttrs)
normalizedDelta = _.pick(normalizedDelta, _.keys(originalDelta))
if _.isEmpty(normalizedDelta)
throw new errors.UnprocessableEntity('Normalized delta is empty.')
# decide whether the patch should be auto-accepted, or left 'pending' for an admin or artisan to review
reasonNotAutoAccepted = undefined
validation = tv4.validateMultiple(newTargetAttrs, Model.jsonSchema)
if not validation.valid
reasonNotAutoAccepted = 'Did not pass json schema.'
else if not isJustFillingTranslations(normalizedDelta)
reasonNotAutoAccepted = 'Adding to existing translations.'
else
# save changes directly to the target, whether versioned or not, and send out notifications
if Model.schema.uses_coco_versions
newVersion = database.initDoc(req, Model)
initNewVersion(newVersion, target)
newVersionAttrs = newVersion.toObject()
jsondiffpatch.patch(newVersionAttrs, normalizedDelta)
newVersion.set(newVersionAttrs)
newVersion.set('commitMessage', req.body.commitMessage)
major = target.get('version.major')
updateI18NCoverage(newVersion)
yield saveNewVersion(newVersion, major)
target = newVersion
else
target.set(newTargetAttrs)
updateI18NCoverage(target)
yield target.save()
notifyChangesMadeToDoc(req, target)
# create, validate and save the patch
if Model.schema.uses_coco_versions
patchTarget = {
collection: collectionName
id: target._id
original: target.get('original')
version: _.pick(target.get('version'), 'major', 'minor')
}
else
patchTarget = {
collection: collectionName
id: target._id
original: target._id
}
patch = new Patch()
patch.set({
delta: normalizedDelta
commitMessage: req.body.commitMessage
target: patchTarget
creator: req.user._id
status: if reasonNotAutoAccepted then 'pending' else 'accepted'
created: new Date().toISOString()
reasonNotAutoAccepted: reasonNotAutoAccepted
})
database.validateDoc(patch)
# add this patch to the denormalized list of patches on the target
if reasonNotAutoAccepted
yield target.update({ $addToSet: { patches: patch._id }})
yield patch.save()
User.incrementStat req.user.id, 'stats.patchesSubmitted'
res.status(201).send(patch.toObject({req: req}))
if reasonNotAutoAccepted
docLink = "https://codecombat.com/editor/#{collectionName}/#{target.id}"
message = "#{req.user.get('name')} submitted a patch to #{target.get('name')}: #{patch.get('commitMessage')} #{docLink}"
slack.sendSlackMessage message, ['artisans']