Convert Discourse.Post to ES6 and use Store model

- Includes acceptance tests for composer (post, edit)
- Supports acceptance testing of bootbox
This commit is contained in:
Robin Ward
2015-04-01 14:18:46 -04:00
parent 19a9a8b408
commit 22ffcba8e6
19 changed files with 747 additions and 440 deletions

View File

@@ -0,0 +1,11 @@
import RestAdapter from 'discourse/adapters/rest';
export default RestAdapter.extend({
// GET /posts doesn't include a type
find(store, type, findArgs) {
return this._super(store, type, findArgs).then(function(result) {
return {post: result};
});
}
});

View File

@@ -61,7 +61,8 @@ export default DiscourseController.extend({
if (postId) {
this.set('model.loading', true);
const composer = this;
return Discourse.Post.load(postId).then(function(post) {
return this.store.find('post', postId).then(function(post) {
const quote = Discourse.Quote.build(post, post.get("raw"));
composer.appendBlockAtCursor(quote);
composer.set('model.loading', false);
@@ -412,7 +413,7 @@ export default DiscourseController.extend({
composerModel.set('topic', opts.topic);
}
} else {
composerModel = composerModel || Discourse.Composer.create();
composerModel = composerModel || Discourse.Composer.create({ store: this.store });
composerModel.open(opts);
}

View File

@@ -323,7 +323,13 @@
// Adds a listener callback to a DOM element which is fired on a specified
// event.
util.addEvent = function (elem, event, listener) {
elem.addEventListener(event, listener, false);
var wrapped = function() {
var wrappedArgs = Array.prototype.slice(arguments);
Ember.run(function() {
listener.call(this, wrappedArgs);
});
};
elem.addEventListener(event, wrapped, false);
};
@@ -904,7 +910,7 @@
// TODO allow us to inject this in (its our debouncer)
var debounce = function(func,wait,trickle) {
var timeout = null;
return function(){
return function() {
var context = this;
var args = arguments;
@@ -924,8 +930,8 @@
currentWait = wait;
}
if (timeout) { clearTimeout(timeout); }
timeout = setTimeout(later, currentWait);
if (timeout) { Ember.run.cancel(timeout); }
timeout = Ember.run.later(later, currentWait);
}
}

View File

@@ -29,7 +29,7 @@ const CLOSED = 'closed',
const Composer = Discourse.Model.extend({
archetypes: function() {
return Discourse.Site.currentProp('archetypes');
return this.site.get('archetypes');
}.property(),
creatingTopic: Em.computed.equal('action', CREATE_TOPIC),
@@ -127,21 +127,16 @@ const Composer = Discourse.Model.extend({
} else {
// has a category? (when needed)
return this.get('canCategorize') &&
!Discourse.SiteSettings.allow_uncategorized_topics &&
!this.siteSettings.allow_uncategorized_topics &&
!this.get('categoryId') &&
!Discourse.User.currentProp('staff');
!this.user.get('staff');
}
}.property('loading', 'canEditTitle', 'titleLength', 'targetUsernames', 'replyLength', 'categoryId', 'missingReplyCharacters'),
/**
Is the title's length valid?
@property titleLengthValid
**/
titleLengthValid: function() {
if (Discourse.User.currentProp('admin') && this.get('post.static_doc') && this.get('titleLength') > 0) return true;
if (this.user.get('admin') && this.get('post.static_doc') && this.get('titleLength') > 0) return true;
if (this.get('titleLength') < this.get('minimumTitleLength')) return false;
return (this.get('titleLength') <= Discourse.SiteSettings.max_topic_title_length);
return (this.get('titleLength') <= this.siteSettings.max_topic_title_length);
}.property('minimumTitleLength', 'titleLength', 'post.static_doc'),
// The icon for the save button
@@ -194,9 +189,9 @@ const Composer = Discourse.Model.extend({
**/
minimumTitleLength: function() {
if (this.get('privateMessage')) {
return Discourse.SiteSettings.min_private_message_title_length;
return this.siteSettings.min_private_message_title_length;
} else {
return Discourse.SiteSettings.min_topic_title_length;
return this.siteSettings.min_topic_title_length;
}
}.property('privateMessage'),
@@ -216,12 +211,12 @@ const Composer = Discourse.Model.extend({
**/
minimumPostLength: function() {
if( this.get('privateMessage') ) {
return Discourse.SiteSettings.min_private_message_post_length;
return this.siteSettings.min_private_message_post_length;
} else if (this.get('topicFirstPost')) {
// first post (topic body)
return Discourse.SiteSettings.min_first_post_length;
return this.siteSettings.min_first_post_length;
} else {
return Discourse.SiteSettings.min_post_length;
return this.siteSettings.min_post_length;
}
}.property('privateMessage', 'topicFirstPost'),
@@ -249,7 +244,7 @@ const Composer = Discourse.Model.extend({
_setupComposer: function() {
const val = (Discourse.Mobile.mobileView ? false : (Discourse.KeyValueStore.get('composer.showPreview') || 'true'));
this.set('showPreview', val === 'true');
this.set('archetypeId', Discourse.Site.currentProp('default_archetype'));
this.set('archetypeId', this.site.get('default_archetype'));
}.on('init'),
/**
@@ -349,15 +344,15 @@ const Composer = Discourse.Model.extend({
this.setProperties({
categoryId: opts.categoryId || this.get('topic.category.id'),
archetypeId: opts.archetypeId || Discourse.Site.currentProp('default_archetype'),
archetypeId: opts.archetypeId || this.site.get('default_archetype'),
metaData: opts.metaData ? Em.Object.create(opts.metaData) : null,
reply: opts.reply || this.get("reply") || ""
});
if (opts.postId) {
this.set('loading', true);
Discourse.Post.load(opts.postId).then(function(result) {
composer.set('post', result);
this.store.find('post', opts.postId).then(function(post) {
composer.set('post', post);
composer.set('loading', false);
});
}
@@ -370,10 +365,10 @@ const Composer = Discourse.Model.extend({
this.setProperties(topicProps);
Discourse.Post.load(opts.post.get('id')).then(function(result) {
this.store.find('post', opts.post.get('id')).then(function(post) {
composer.setProperties({
reply: result.get('raw'),
originalText: result.get('raw'),
reply: post.get('raw'),
originalText: post.get('raw'),
loading: false
});
});
@@ -467,7 +462,7 @@ const Composer = Discourse.Model.extend({
createPost(opts) {
const post = this.get('post'),
topic = this.get('topic'),
currentUser = Discourse.User.current(),
user = this.user,
postStream = this.get('topic.postStream');
let addedToStream = false;
@@ -477,17 +472,17 @@ const Composer = Discourse.Model.extend({
imageSizes: opts.imageSizes,
cooked: this.getCookedHtml(),
reply_count: 0,
name: currentUser.get('name'),
display_username: currentUser.get('name'),
username: currentUser.get('username'),
user_id: currentUser.get('id'),
user_title: currentUser.get('title'),
uploaded_avatar_id: currentUser.get('uploaded_avatar_id'),
user_custom_fields: currentUser.get('custom_fields'),
post_type: Discourse.Site.currentProp('post_types.regular'),
name: user.get('name'),
display_username: user.get('name'),
username: user.get('username'),
user_id: user.get('id'),
user_title: user.get('title'),
uploaded_avatar_id: user.get('uploaded_avatar_id'),
user_custom_fields: user.get('custom_fields'),
post_type: this.site.get('post_types.regular'),
actions_summary: [],
moderator: currentUser.get('moderator'),
admin: currentUser.get('admin'),
moderator: user.get('moderator'),
admin: user.get('admin'),
yours: true,
newPost: true,
read: true
@@ -520,7 +515,7 @@ const Composer = Discourse.Model.extend({
// we would need to handle oneboxes and other bits that are not even in the
// engine, staging will just cause a blank post to render
if (!_.isEmpty(createdPost.get('cooked'))) {
state = postStream.stagePost(createdPost, currentUser);
state = postStream.stagePost(createdPost, user);
if(state === "alreadyStaging"){
return;
@@ -529,69 +524,64 @@ const Composer = Discourse.Model.extend({
}
}
const composer = this,
promise = new Ember.RSVP.Promise(function(resolve, reject) {
composer.set('composeState', SAVING);
createdPost.save(function(result) {
let saving = true;
createdPost.updateFromJson(result);
if (topic) {
// It's no longer a new post
createdPost.set('newPost', false);
topic.set('draft_sequence', result.draft_sequence);
postStream.commitPost(createdPost);
addedToStream = true;
} else {
// We created a new topic, let's show it.
composer.set('composeState', CLOSED);
saving = false;
// Update topic_count for the category
const category = Discourse.Site.currentProp('categories').find(function(x) { return x.get('id') === (parseInt(createdPost.get('category'),10) || 1); });
if (category) category.incrementProperty('topic_count');
Discourse.notifyPropertyChange('globalNotice');
}
composer.clearState();
composer.set('createdPost', createdPost);
if (addedToStream) {
composer.set('composeState', CLOSED);
} else if (saving) {
composer.set('composeState', SAVING);
}
return resolve({ post: result });
}, function(error) {
// If an error occurs
if (postStream) {
postStream.undoPost(createdPost);
}
composer.set('composeState', OPEN);
// TODO extract error handling code
let parsedError;
try {
const parsedJSON = $.parseJSON(error.responseText);
if (parsedJSON.errors) {
parsedError = parsedJSON.errors[0];
} else if (parsedJSON.failed) {
parsedError = parsedJSON.message;
}
}
catch(ex) {
parsedError = "Unknown error saving post, try again. Error: " + error.status + " " + error.statusText;
}
reject(parsedError);
});
});
const composer = this;
composer.set('composeState', SAVING);
composer.set("stagedPost", state === "staged" && createdPost);
return promise;
return createdPost.save().then(function(result) {
let saving = true;
createdPost.updateFromJson(result);
if (topic) {
// It's no longer a new post
createdPost.set('newPost', false);
topic.set('draft_sequence', result.draft_sequence);
postStream.commitPost(createdPost);
addedToStream = true;
} else {
// We created a new topic, let's show it.
composer.set('composeState', CLOSED);
saving = false;
// Update topic_count for the category
const category = composer.site.get('categories').find(function(x) { return x.get('id') === (parseInt(createdPost.get('category'),10) || 1); });
if (category) category.incrementProperty('topic_count');
Discourse.notifyPropertyChange('globalNotice');
}
composer.clearState();
composer.set('createdPost', createdPost);
if (addedToStream) {
composer.set('composeState', CLOSED);
} else if (saving) {
composer.set('composeState', SAVING);
}
return { post: result };
}).catch(function(error) {
// If an error occurs
if (postStream) {
postStream.undoPost(createdPost);
}
composer.set('composeState', OPEN);
// TODO extract error handling code
let parsedError;
try {
const parsedJSON = $.parseJSON(error.responseText);
if (parsedJSON.errors) {
parsedError = parsedJSON.errors[0];
} else if (parsedJSON.failed) {
parsedError = parsedJSON.message;
}
}
catch(ex) {
parsedError = "Unknown error saving post, try again. Error: " + error.status + " " + error.statusText;
}
throw parsedError;
});
},
getCookedHtml() {
@@ -604,7 +594,7 @@ const Composer = Discourse.Model.extend({
// Do not save when there is no reply
if (!this.get('reply')) return;
// Do not save when the reply's length is too small
if (this.get('replyLength') < Discourse.SiteSettings.min_post_length) return;
if (this.get('replyLength') < this.siteSettings.min_post_length) return;
const data = {
reply: this.get('reply'),
@@ -673,6 +663,14 @@ Composer.reopenClass({
}
},
create(args) {
args = args || {};
args.user = args.user || Discourse.User.current();
args.site = args.site || Discourse.Site.current();
args.siteSettings = args.siteSettings || Discourse.SiteSettings;
return this._super(args);
},
serializeToTopic(fieldName, property) {
if (!property) { property = fieldName; }
_edit_topic_serializer[fieldName] = property;

View File

@@ -1,20 +1,12 @@
/**
A data model representing a post in a topic
const Post = Discourse.Model.extend({
@class Post
@extends Discourse.Model
@namespace Discourse
@module Discourse
**/
Discourse.Post = Discourse.Model.extend({
init: function() {
init() {
this.set('replyHistory', []);
},
shareUrl: function() {
var user = Discourse.User.current();
var userSuffix = user ? '?u=' + user.get('username_lower') : '';
const user = Discourse.User.current();
const userSuffix = user ? '?u=' + user.get('username_lower') : '';
if (this.get('firstPost')) {
return this.get('topic.url') + userSuffix;
@@ -33,7 +25,7 @@ Discourse.Post = Discourse.Model.extend({
userDeleted: Em.computed.empty('user_id'),
showName: function() {
var name = this.get('name');
const name = this.get('name');
return name && (name !== this.get('username')) && Discourse.SiteSettings.display_name_on_posts;
}.property('name', 'username'),
@@ -69,17 +61,17 @@ Discourse.Post = Discourse.Model.extend({
}.property("user_id"),
wikiChanged: function() {
var data = { wiki: this.get("wiki") };
const data = { wiki: this.get("wiki") };
this._updatePost("wiki", data);
}.observes('wiki'),
postTypeChanged: function () {
var data = { post_type: this.get("post_type") };
const data = { post_type: this.get("post_type") };
this._updatePost("post_type", data);
}.observes("post_type"),
_updatePost: function (field, data) {
var self = this;
_updatePost(field, data) {
const self = this;
Discourse.ajax("/posts/" + this.get("id") + "/" + field, {
type: "PUT",
data: data
@@ -103,7 +95,7 @@ Discourse.Post = Discourse.Model.extend({
editCount: function() { return this.get('version') - 1; }.property('version'),
flagsAvailable: function() {
var post = this;
const post = this;
return Discourse.Site.currentProp('flagTypes').filter(function(item) {
return post.get("actionByName." + item.get('name_key') + ".can_act");
});
@@ -119,9 +111,8 @@ Discourse.Post = Discourse.Model.extend({
});
}.property('actions_summary.@each.users', 'actions_summary.@each.count'),
// Save a post and call the callback when done.
save: function(complete, error) {
var self = this;
save() {
const self = this;
if (!this.get('newPost')) {
// We're updating a post
return Discourse.ajax("/posts/" + (this.get('id')), {
@@ -135,19 +126,17 @@ Discourse.Post = Discourse.Model.extend({
// If we received a category update, update it
self.set('version', result.post.version);
if (result.category) Discourse.Site.current().updateCategory(result.category);
if (complete) complete(Discourse.Post.create(result.post));
}).catch(function(result) {
// Post failed to update
if (error) error(result);
return Discourse.Post.create(result.post);
});
} else {
// We're saving a post
var data = this.getProperties(Discourse.Composer.serializedFieldsForCreate());
const data = this.getProperties(Discourse.Composer.serializedFieldsForCreate());
data.reply_to_post_number = this.get('reply_to_post_number');
data.image_sizes = this.get('imageSizes');
data.nested_post = true;
var metaData = this.get('metaData');
const metaData = this.get('metaData');
// Put the metaData into the request
if (metaData) {
data.meta_data = {};
@@ -158,34 +147,22 @@ Discourse.Post = Discourse.Model.extend({
type: 'POST',
data: data
}).then(function(result) {
// Post created
if (complete) complete(Discourse.Post.create(result));
}).catch(function(result) {
// Failed to create a post
if (error) error(result);
return Discourse.Post.create(result.post);
});
}
},
/**
Expands the first post's content, if embedded and shortened.
@method expandFirstPost
**/
expand: function() {
var self = this;
// Expands the first post's content, if embedded and shortened.
expand() {
const self = this;
return Discourse.ajax("/posts/" + this.get('id') + "/expand-embed").then(function(post) {
self.set('cooked', "<section class='expanded-embed'>" + post.cooked + "</section>" );
});
},
/**
Recover a deleted post
@method recover
**/
recover: function() {
var post = this;
// Recover a deleted post
recover() {
const post = this;
post.setProperties({
deleted_at: null,
deleted_by: null,
@@ -207,11 +184,8 @@ Discourse.Post = Discourse.Model.extend({
/**
Changes the state of the post to be deleted. Does not call the server, that should be
done elsewhere.
@method setDeletedState
@param {Discourse.User} deletedBy The user deleting the post
**/
setDeletedState: function(deletedBy) {
setDeletedState(deletedBy) {
this.set('oldCooked', this.get('cooked'));
// Moderators can delete posts. Users can only trigger a deleted at message, unless delete_removed_posts_after is 0.
@@ -237,10 +211,8 @@ Discourse.Post = Discourse.Model.extend({
Changes the state of the post to NOT be deleted. Does not call the server.
This can only be called after setDeletedState was called, but the delete
failed on the server.
@method undoDeletedState
**/
undoDeleteState: function() {
undoDeleteState() {
if (this.get('oldCooked')) {
this.setProperties({
deleted_at: null,
@@ -253,13 +225,7 @@ Discourse.Post = Discourse.Model.extend({
}
},
/**
Deletes a post
@method destroy
@param {Discourse.User} deletedBy The user deleting the post
**/
destroy: function(deletedBy) {
destroy(deletedBy) {
this.setDeletedState(deletedBy);
return Discourse.ajax("/posts/" + this.get('id'), {
data: { context: window.location.pathname },
@@ -270,14 +236,11 @@ Discourse.Post = Discourse.Model.extend({
/**
Updates a post from another's attributes. This will normally happen when a post is loading but
is already found in an identity map.
@method updateFromPost
@param {Discourse.Post} otherPost The post we're updating from
**/
updateFromPost: function(otherPost) {
var self = this;
updateFromPost(otherPost) {
const self = this;
Object.keys(otherPost).forEach(function (key) {
var value = otherPost[key],
let value = otherPost[key],
oldValue = self[key];
if (key === "replyHistory") {
@@ -287,7 +250,7 @@ Discourse.Post = Discourse.Model.extend({
if (!value) { value = null; }
if (!oldValue) { oldValue = null; }
var skip = false;
let skip = false;
if (typeof value !== "function" && oldValue !== value) {
// wishing for an identity map
if (key === "reply_to_user" && value && oldValue) {
@@ -304,17 +267,14 @@ Discourse.Post = Discourse.Model.extend({
/**
Updates a post from a JSON packet. This is normally done after the post is saved to refresh any
attributes.
@method updateFromJson
@param {Object} obj The Json data to update with
**/
updateFromJson: function(obj) {
updateFromJson(obj) {
if (!obj) return;
var skip, oldVal;
let skip, oldVal;
// Update all the properties
var post = this;
const post = this;
_.each(obj, function(val,key) {
if (key !== 'actions_summary'){
oldVal = post[key];
@@ -336,12 +296,11 @@ Discourse.Post = Discourse.Model.extend({
// Rebuild actions summary
this.set('actions_summary', Em.A());
if (obj.actions_summary) {
var lookup = Em.Object.create();
const lookup = Em.Object.create();
_.each(obj.actions_summary,function(a) {
var actionSummary;
a.post = post;
a.actionType = Discourse.Site.current().postActionTypeById(a.id);
actionSummary = Discourse.ActionSummary.create(a);
const actionSummary = Discourse.ActionSummary.create(a);
post.get('actions_summary').pushObject(actionSummary);
lookup.set(a.actionType.get('name_key'), actionSummary);
});
@@ -350,7 +309,7 @@ Discourse.Post = Discourse.Model.extend({
},
// Load replies to this post
loadReplies: function() {
loadReplies() {
if(this.get('loadingReplies')){
return;
}
@@ -358,12 +317,12 @@ Discourse.Post = Discourse.Model.extend({
this.set('loadingReplies', true);
this.set('replies', []);
var self = this;
const self = this;
return Discourse.ajax("/posts/" + (this.get('id')) + "/replies")
.then(function(loaded) {
var replies = self.get('replies');
const replies = self.get('replies');
_.each(loaded,function(reply) {
var post = Discourse.Post.create(reply);
const post = Discourse.Post.create(reply);
post.set('topic', self.get('topic'));
replies.pushObject(post);
});
@@ -375,7 +334,7 @@ Discourse.Post = Discourse.Model.extend({
// Whether to show replies directly below
showRepliesBelow: function() {
var replyCount = this.get('reply_count');
const replyCount = this.get('reply_count');
// We don't show replies if there aren't any
if (replyCount === 0) return false;
@@ -387,13 +346,13 @@ Discourse.Post = Discourse.Model.extend({
if (replyCount > 1) return true;
// If we have *exactly* one reply, we have to consider if it's directly below us
var topic = this.get('topic');
const topic = this.get('topic');
return !topic.isReplyDirectlyBelow(this);
}.property('reply_count'),
expandHidden: function() {
var self = this;
expandHidden() {
const self = this;
return Discourse.ajax("/posts/" + this.get('id') + "/cooked.json").then(function (result) {
self.setProperties({
cooked: result.cooked,
@@ -402,17 +361,17 @@ Discourse.Post = Discourse.Model.extend({
});
},
rebake: function () {
rebake() {
return Discourse.ajax("/posts/" + this.get("id") + "/rebake", { type: "PUT" });
},
unhide: function () {
unhide() {
return Discourse.ajax("/posts/" + this.get("id") + "/unhide", { type: "PUT" });
},
toggleBookmark: function() {
var self = this,
bookmarkedTopic;
toggleBookmark() {
const self = this;
let bookmarkedTopic;
this.toggleProperty("bookmarked");
@@ -435,16 +394,16 @@ Discourse.Post = Discourse.Model.extend({
}
});
Discourse.Post.reopenClass({
Post.reopenClass({
createActionSummary: function(result) {
createActionSummary(result) {
if (result.actions_summary) {
var lookup = Em.Object.create();
const lookup = Em.Object.create();
// this area should be optimized, it is creating way too many objects per post
result.actions_summary = result.actions_summary.map(function(a) {
a.post = result;
a.actionType = Discourse.Site.current().postActionTypeById(a.id);
var actionSummary = Discourse.ActionSummary.create(a);
const actionSummary = Discourse.ActionSummary.create(a);
lookup[a.actionType.name_key] = actionSummary;
return actionSummary;
});
@@ -452,8 +411,8 @@ Discourse.Post.reopenClass({
}
},
create: function(obj) {
var result = this._super.apply(this, arguments);
create(obj) {
const result = this._super.apply(this, arguments);
this.createActionSummary(result);
if (obj && obj.reply_to_user) {
result.set('reply_to_user', Discourse.User.create(obj.reply_to_user));
@@ -461,14 +420,14 @@ Discourse.Post.reopenClass({
return result;
},
updateBookmark: function(postId, bookmarked) {
updateBookmark(postId, bookmarked) {
return Discourse.ajax("/posts/" + postId + "/bookmark", {
type: 'PUT',
data: { bookmarked: bookmarked }
});
},
deleteMany: function(selectedPosts, selectedReplies) {
deleteMany(selectedPosts, selectedReplies) {
return Discourse.ajax("/posts/destroy_many", {
type: 'DELETE',
data: {
@@ -478,37 +437,33 @@ Discourse.Post.reopenClass({
});
},
loadRevision: function(postId, version) {
loadRevision(postId, version) {
return Discourse.ajax("/posts/" + postId + "/revisions/" + version + ".json").then(function (result) {
return Ember.Object.create(result);
});
},
hideRevision: function(postId, version) {
hideRevision(postId, version) {
return Discourse.ajax("/posts/" + postId + "/revisions/" + version + "/hide", { type: 'PUT' });
},
showRevision: function(postId, version) {
showRevision(postId, version) {
return Discourse.ajax("/posts/" + postId + "/revisions/" + version + "/show", { type: 'PUT' });
},
loadQuote: function(postId) {
loadQuote(postId) {
return Discourse.ajax("/posts/" + postId + ".json").then(function (result) {
var post = Discourse.Post.create(result);
const post = Discourse.Post.create(result);
return Discourse.Quote.build(post, post.get('raw'));
});
},
loadRawEmail: function(postId) {
loadRawEmail(postId) {
return Discourse.ajax("/posts/" + postId + "/raw-email").then(function (result) {
return result.raw_email;
});
},
load: function(postId) {
return Discourse.ajax("/posts/" + postId + ".json").then(function (result) {
return Discourse.Post.create(result);
});
}
});
export default Post;

View File

@@ -25,6 +25,7 @@
//= require ./discourse/lib/safari-hacks
//= require_tree ./discourse/adapters
//= require ./discourse/models/model
//= require ./discourse/models/post
//= require ./discourse/models/user_action
//= require ./discourse/models/composer
//= require ./discourse/models/post-stream

View File

@@ -336,7 +336,11 @@ class PostsController < ApplicationController
# doesn't return the post as the root JSON object, but as a nested object.
# If a param is present it uses that result structure.
def backwards_compatible_json(json_obj, success)
json_obj = json_obj[:post] || json_obj['post'] unless params[:nested_post]
json_obj.symbolize_keys!
if params[:nested_post].blank? && json_obj[:errors].blank?
json_obj = json_obj[:post]
end
render json: json_obj, status: (!!success) ? 200 : 422
end