Added labels for Members (#11538)

no issue

* Updated sendEmailWithMagicLink syntax

* Updated label name selection from theme

* Updated migration version for labels

* Added labels to export/import of members

* Added member labels sanitization for case-insensitive duplicates

* Fixed tests

* Fixed label serialization bug on import

* Bumped @tryghost/members-api to 0.15.0

* Fixed lint

* Cleanup
This commit is contained in:
Rishabh Garg 2020-02-14 15:03:10 +05:30 committed by GitHub
parent aff289bfee
commit 001db05075
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 864 additions and 28 deletions

View File

@ -71,6 +71,10 @@ module.exports = {
return shared.pipeline(require('./members'), localUtils);
},
get labels() {
return shared.pipeline(require('./labels'), localUtils);
},
get images() {
return shared.pipeline(require('./images'), localUtils);
},

View File

@ -0,0 +1,145 @@
const Promise = require('bluebird');
const common = require('../../lib/common');
const models = require('../../models');
const ALLOWED_INCLUDES = ['count.members'];
module.exports = {
docName: 'labels',
browse: {
options: [
'include',
'filter',
'fields',
'limit',
'order',
'page'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.Label.findPage(frame.options);
}
},
read: {
options: [
'include',
'filter',
'fields'
],
data: [
'id',
'slug'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.Label.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.api.labels.labelNotFound')
}));
}
return model;
});
}
},
add: {
statusCode: 201,
headers: {
cacheInvalidate: true
},
options: [
'include'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.Label.add(frame.data.labels[0], frame.options);
}
},
edit: {
headers: {},
options: [
'id',
'include'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
id: {
required: true
}
}
},
permissions: true,
query(frame) {
return models.Label.edit(frame.data.labels[0], frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.api.labels.labelNotFound')
}));
}
if (model.wasChanged()) {
this.headers.cacheInvalidate = true;
} else {
this.headers.cacheInvalidate = false;
}
return model;
});
}
},
destroy: {
statusCode: 204,
headers: {
cacheInvalidate: true
},
options: [
'id'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
id: {
required: true
}
}
},
permissions: true,
query(frame) {
return models.Label.destroy(frame.options).return(null);
}
}
};

View File

@ -5,6 +5,7 @@ const models = require('../../models');
const membersService = require('../../services/members');
const common = require('../../lib/common');
const fsLib = require('../../lib/fs');
const _ = require('lodash');
const decorateWithSubscriptions = async function (member) {
// NOTE: this logic is here until relations between Members/MemberStripeCustomer/StripeCustomerSubscription
@ -59,6 +60,22 @@ const sanitizeInput = (members) => {
return sanitized;
};
function serializeMemberLabels(labels) {
if (labels) {
return labels.filter((label) => {
return !!label;
}).map((label) => {
if (_.isString(label)) {
return {
name: label.trim()
};
}
return label;
});
}
return [];
}
const listMembers = async function (options) {
const res = (await models.Member.findPage(options));
const memberModels = res.data.map(model => model.toJSON(options));
@ -149,7 +166,7 @@ const members = {
}
if (frame.options.send_email) {
await membersService.api.sendEmailWithMagicLink(model.get('email'), frame.options.email_type);
await membersService.api.sendEmailWithMagicLink({email: model.get('email'), requestedType: frame.options.email_type});
}
return decorateWithSubscriptions(member);
@ -275,6 +292,7 @@ const members = {
},
validation: {},
async query(frame) {
frame.options.withRelated = ['labels'];
return listMembers(frame.options);
}
},
@ -308,6 +326,9 @@ const members = {
}, {
name: 'complimentary_plan',
lookup: /complimentary_plan/i
}, {
name: 'labels',
lookup: /labels/i
}];
return fsLib.readCSV({
@ -319,7 +340,8 @@ const members = {
return Promise.map(sanitized, ((entry) => {
const api = require('./index');
entry.labels = (entry.labels && entry.labels.split(',')) || [];
const entryLabels = serializeMemberLabels(entry.labels);
cleanupUndefined(entry);
return Promise.resolve(api.members.add.query({
data: {
@ -329,7 +351,8 @@ const members = {
note: entry.note,
subscribed: (String(entry.subscribed_to_emails).toLowerCase() === 'true'),
stripe_customer_id: entry.stripe_customer_id,
comped: (String(entry.complimentary_plan).toLocaleLowerCase() === 'true')
comped: (String(entry.complimentary_plan).toLocaleLowerCase() === 'true'),
labels: entryLabels
}]
},
options: {

View File

@ -25,5 +25,9 @@ module.exports = {
get tags() {
return require('./tags');
},
get members() {
return require('./members');
}
};

View File

@ -0,0 +1,46 @@
const _ = require('lodash');
const debug = require('ghost-ignition').debug('api:canary:utils:serializers:input:members');
function defaultRelations(frame) {
if (frame.options.withRelated) {
return;
}
if (frame.options.columns && !frame.options.withRelated) {
return false;
}
frame.options.withRelated = ['labels'];
}
module.exports = {
browse(apiConfig, frame) {
debug('browse');
defaultRelations(frame);
},
read() {
debug('read');
this.browse(...arguments);
},
add(apiConfig, frame) {
debug('add');
if (frame.data.members[0].labels) {
frame.data.members[0].labels.forEach((label, index) => {
if (_.isString(label)) {
frame.data.members[0].labels[index] = {
name: label
};
}
});
}
defaultRelations(frame);
},
edit(apiConfig, frame) {
debug('edit');
this.add(apiConfig, frame);
}
};

View File

@ -109,5 +109,9 @@ module.exports = {
get emails() {
return require('./emails');
},
get labels() {
return require('./labels');
}
};

View File

@ -0,0 +1,25 @@
const debug = require('ghost-ignition').debug('api:canary:utils:serializers:output:labels');
const mapper = require('./utils/mapper');
module.exports = {
all(models, apiConfig, frame) {
debug('all');
if (!models) {
return;
}
if (models.meta) {
frame.response = {
labels: models.data.map(model => mapper.mapLabel(model, frame)),
meta: models.meta
};
return;
}
frame.response = {
labels: [mapper.mapLabel(models, frame)]
};
}
};

View File

@ -47,7 +47,7 @@ module.exports = {
exportCSV(models, apiConfig, frame) {
debug('exportCSV');
const fields = ['id', 'email', 'name', 'note', 'subscribed_to_emails', 'complimentary_plan', 'stripe_customer_id', 'created_at', 'deleted_at'];
const fields = ['id', 'email', 'name', 'note', 'subscribed_to_emails', 'complimentary_plan', 'stripe_customer_id', 'created_at', 'deleted_at', 'labels'];
models.members = models.members.map((member) => {
member = mapper.mapMember(member);
@ -56,6 +56,10 @@ module.exports = {
if (member.stripe) {
stripeCustomerId = _.get(member, 'stripe.subscriptions[0].customer.id');
}
let labels = [];
if (member.labels) {
labels = `"${member.labels.map(l => l.name).join(',')}"`;
}
return {
id: member.id,
@ -66,7 +70,8 @@ module.exports = {
complimentary_plan: member.comped,
stripe_customer_id: stripeCustomerId,
created_at: member.created_at,
deleted_at: member.deleted_at
deleted_at: member.deleted_at,
labels: labels
};
});

View File

@ -157,10 +157,16 @@ const mapMember = (model, frame) => {
return jsonModel;
};
const mapLabel = (model, frame) => {
const jsonModel = model.toJSON ? model.toJSON(frame.options) : model;
return jsonModel;
};
module.exports.mapPost = mapPost;
module.exports.mapPage = mapPage;
module.exports.mapUser = mapUser;
module.exports.mapTag = mapTag;
module.exports.mapLabel = mapLabel;
module.exports.mapIntegration = mapIntegration;
module.exports.mapSettings = mapSettings;
module.exports.mapImage = mapImage;

View File

@ -35,6 +35,10 @@ module.exports = {
return require('./tags');
},
get labels() {
return require('./labels');
},
get users() {
return require('./users');
},

View File

@ -0,0 +1,15 @@
const jsonSchema = require('../utils/json-schema');
module.exports = {
add(apiConfig, frame) {
const schema = require('./schemas/labels-add');
const definitions = require('./schemas/labels');
return jsonSchema.validate(schema, definitions, frame.data);
},
edit(apiConfig, frame) {
const schema = require('./schemas/labels-edit');
const definitions = require('./schemas/labels');
return jsonSchema.validate(schema, definitions, frame.data);
}
};

View File

@ -0,0 +1,23 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "labels.add",
"title": "labels.add",
"description": "Schema for labels.add",
"type": "object",
"additionalProperties": false,
"properties": {
"labels": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"additionalProperties": false,
"items": {
"type": "object",
"allOf": [{"$ref": "labels#/definitions/label"}],
"required": ["name"]
}
}
},
"required": [ "labels" ]
}

View File

@ -0,0 +1,18 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "labels.edit",
"title": "labels.edit",
"description": "Schema for labels.edit",
"type": "object",
"additionalProperties": false,
"properties": {
"labels": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {"$ref": "labels#/definitions/label"}
}
},
"required": [ "labels" ]
}

View File

@ -0,0 +1,40 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "labels",
"title": "labels",
"description": "Base labels definitions",
"definitions": {
"label": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^([^,]|$)"
},
"slug": {
"type": ["string", "null"],
"maxLength": 191
},
"id": {
"strip": true
},
"created_at": {
"strip": true
},
"created_by": {
"strip": true
},
"updated_at": {
"strip": true
},
"updated_by": {
"strip": true
}
}
}
}
}

View File

@ -37,6 +37,9 @@
"email": {
"strip": true
},
"labels": {
"$ref": "members#/definitions/member-labels"
},
"created_at": {
"strip": true
},
@ -54,4 +57,4 @@
}
},
"required": ["members"]
}
}

View File

@ -34,6 +34,9 @@
"id": {
"strip": true
},
"labels": {
"$ref": "#/definitions/member-labels"
},
"created_at": {
"strip": true
},
@ -47,6 +50,40 @@
"strip": true
}
}
},
"member-labels": {
"description": "Labels of the member",
"type": "array",
"items": {
"anyOf": [{
"type": "object",
"properties": {
"id": {
"type": "string",
"maxLength": 24
},
"name": {
"type": "string",
"maxLength": 191
},
"slug": {
"type": ["string", "null"],
"maxLength": 191
},
"members": {
"strip": true
}
},
"anyOf": [
{"required": ["id"]},
{"required": ["name"]},
{"required": ["slug"]}
]
}, {
"type": "string",
"maxLength": 191
}]
}
}
}
}

View File

@ -0,0 +1,35 @@
const common = require('../../../../lib/common');
const commands = require('../../../schema').commands;
const table = 'labels';
const message1 = 'Adding table: ' + table;
const message2 = 'Dropping table: ' + table;
module.exports.up = (options) => {
const connection = options.connection;
return connection.schema.hasTable(table)
.then(function (exists) {
if (exists) {
common.logging.warn(message1);
return;
}
common.logging.info(message1);
return commands.createTable(table, connection);
});
};
module.exports.down = (options) => {
const connection = options.connection;
return connection.schema.hasTable(table)
.then(function (exists) {
if (!exists) {
common.logging.warn(message2);
return;
}
common.logging.info(message2);
return commands.deleteTable(table, connection);
});
};

View File

@ -0,0 +1,35 @@
const common = require('../../../../lib/common');
const commands = require('../../../schema').commands;
const table = 'members_labels';
const message1 = 'Adding table: ' + table;
const message2 = 'Dropping table: ' + table;
module.exports.up = (options) => {
const connection = options.connection;
return connection.schema.hasTable(table)
.then(function (exists) {
if (exists) {
common.logging.warn(message1);
return;
}
common.logging.info(message1);
return commands.createTable(table, connection);
});
};
module.exports.down = (options) => {
const connection = options.connection;
return connection.schema.hasTable(table)
.then(function (exists) {
if (!exists) {
common.logging.warn(message2);
return;
}
common.logging.info(message2);
return commands.deleteTable(table, connection);
});
};

View File

@ -0,0 +1,58 @@
const _ = require('lodash');
const utils = require('../../../schema/fixtures/utils');
const permissions = require('../../../../services/permissions');
const logging = require('../../../../lib/common/logging');
const resources = ['label'];
const _private = {};
_private.getPermissions = function getPermissions(resource) {
return utils.findModelFixtures('Permission', {object_type: resource});
};
_private.getRelations = function getRelations(resource) {
return utils.findPermissionRelationsForObject(resource);
};
_private.printResult = function printResult(result, message) {
if (result.done === result.expected) {
logging.info(message);
} else {
logging.warn(`(${result.done}/${result.expected}) ${message}`);
}
};
module.exports.config = {
transaction: true
};
module.exports.up = (options) => {
const localOptions = _.merge({
context: {internal: true}
}, options);
return Promise.map(resources, (resource) => {
const modelToAdd = _private.getPermissions(resource);
const relationToAdd = _private.getRelations(resource);
return utils.addFixturesForModel(modelToAdd, localOptions)
.then(result => _private.printResult(result, `Adding permissions fixtures for ${resource}s`))
.then(() => utils.addFixturesForRelation(relationToAdd, localOptions))
.then(result => _private.printResult(result, `Adding permissions_roles fixtures for ${resource}s`))
.then(() => permissions.init(localOptions));
});
};
module.exports.down = (options) => {
const localOptions = _.merge({
context: {internal: true}
}, options);
return Promise.map(resources, (resource) => {
const modelToRemove = _private.getPermissions(resource);
// permission model automatically cleans up permissions_roles on .destroy()
return utils.removeFixturesForModel(modelToRemove, localOptions)
.then(result => _private.printResult(result, `Removing permissions fixtures for ${resource}s`));
});
};

View File

@ -353,6 +353,31 @@
"action_type": "destroy",
"object_type": "member"
},
{
"name": "Browse labels",
"action_type": "browse",
"object_type": "label"
},
{
"name": "Read labels",
"action_type": "read",
"object_type": "label"
},
{
"name": "Edit labels",
"action_type": "edit",
"object_type": "label"
},
{
"name": "Add labels",
"action_type": "add",
"object_type": "label"
},
{
"name": "Delete labels",
"action_type": "destroy",
"object_type": "label"
},
{
"name": "Publish posts",
"action_type": "publish",
@ -582,6 +607,7 @@
"api_key": "all",
"action": "all",
"member": "all",
"label": "all",
"email_preview": "all",
"email": "all"
},
@ -606,6 +632,7 @@
"webhook": "all",
"action": "all",
"member": "all",
"label": "all",
"email_preview": "all",
"email": "all"
},

View File

@ -333,6 +333,21 @@ module.exports = {
updated_at: {type: 'dateTime', nullable: true},
updated_by: {type: 'string', maxlength: 24, nullable: true}
},
labels: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
name: {type: 'string', maxlength: 191, nullable: false, unique: true},
slug: {type: 'string', maxlength: 191, nullable: false, unique: true},
created_at: {type: 'dateTime', nullable: false},
created_by: {type: 'string', maxlength: 24, nullable: false},
updated_at: {type: 'dateTime', nullable: true},
updated_by: {type: 'string', maxlength: 24, nullable: true}
},
members_labels: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
member_id: {type: 'string', maxlength: 24, nullable: false, references: 'posts.id'},
label_id: {type: 'string', maxlength: 24, nullable: false, references: 'tags.id'},
sort_order: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0}
},
members_stripe_customers: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
member_id: {type: 'string', maxlength: 24, nullable: false, unique: false},

View File

@ -913,6 +913,8 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
data: data,
meta: {pagination: response.pagination}
};
}).catch((err) => {
throw err;
});
},

View File

@ -37,7 +37,8 @@ models = [
'posts-meta',
'member-stripe-customer',
'stripe-customer-subscription',
'email'
'email',
'label'
];
function init() {

126
core/server/models/label.js Normal file
View File

@ -0,0 +1,126 @@
const ghostBookshelf = require('./base');
let Label, Labels;
Label = ghostBookshelf.Model.extend({
tableName: 'labels',
emitChange: function emitChange(event, options) {
const eventToTrigger = 'label' + '.' + event;
ghostBookshelf.Model.prototype.emitChange.bind(this)(this, eventToTrigger, options);
},
onCreated: function onCreated(model, attrs, options) {
ghostBookshelf.Model.prototype.onCreated.apply(this, arguments);
model.emitChange('added', options);
},
onUpdated: function onUpdated(model, attrs, options) {
ghostBookshelf.Model.prototype.onUpdated.apply(this, arguments);
model.emitChange('edited', options);
},
onDestroyed: function onDestroyed(model, options) {
ghostBookshelf.Model.prototype.onDestroyed.apply(this, arguments);
model.emitChange('deleted', options);
},
onSaving: function onSaving(newLabel, attr, options) {
var self = this;
ghostBookshelf.Model.prototype.onSaving.apply(this, arguments);
// Make sure name is trimmed of extra spaces
let name = this.get('name') && this.get('name').trim();
this.set('name', name);
if (this.hasChanged('slug') || (!this.get('slug') && this.get('name'))) {
// Pass the new slug through the generator to strip illegal characters, detect duplicates
return ghostBookshelf.Model.generateSlug(Label, this.get('slug') || this.get('name'),
{transacting: options.transacting})
.then(function then(slug) {
self.set({slug: slug});
});
}
},
members: function members() {
return this.belongsToMany('Member', 'members_labels', 'label_id', 'member_id');
},
toJSON: function toJSON(unfilteredOptions) {
const options = Label.filterOptions(unfilteredOptions, 'toJSON');
const attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options);
return attrs;
},
getAction(event, options) {
const actor = this.getActor(options);
// @NOTE: we ignore internal updates (`options.context.internal`) for now
if (!actor) {
return;
}
// @TODO: implement context
return {
event: event,
resource_id: this.id || this.previous('id'),
resource_type: 'label',
actor_id: actor.id,
actor_type: actor.type
};
}
}, {
orderDefaultOptions: function orderDefaultOptions() {
return {
name: 'ASC',
created_at: 'DESC'
};
},
permittedOptions: function permittedOptions(methodName) {
var options = ghostBookshelf.Model.permittedOptions.call(this, methodName),
// whitelists for the `options` hash argument on methods, by method name.
// these are the only options that can be passed to Bookshelf / Knex.
validOptions = {
findAll: ['columns'],
findOne: ['columns'],
destroy: ['destroyAll']
};
if (validOptions[methodName]) {
options = options.concat(validOptions[methodName]);
}
return options;
},
destroy: function destroy(unfilteredOptions) {
var options = this.filterOptions(unfilteredOptions, 'destroy', {extraAllowedProperties: ['id']});
options.withRelated = ['members'];
return this.forge({id: options.id})
.fetch(options)
.then(function destroyLabelsAndMember(label) {
return label.related('members')
.detach(null, options)
.then(function destroyLabels() {
return label.destroy(options);
});
});
}
});
Labels = ghostBookshelf.Collection.extend({
model: Label
});
module.exports = {
Label: ghostBookshelf.model('Label', Label),
Labels: ghostBookshelf.collection('Labels', Labels)
};

View File

@ -1,5 +1,7 @@
const ghostBookshelf = require('./base');
const uuid = require('uuid');
const _ = require('lodash');
const sequence = require('../lib/promise/sequence');
const Member = ghostBookshelf.Model.extend({
tableName: 'members',
@ -11,6 +13,18 @@ const Member = ghostBookshelf.Model.extend({
};
},
relationships: ['labels'],
relationshipBelongsTo: {
labels: 'labels'
},
labels: function labels() {
return this.belongsToMany('Label', 'members_labels', 'member_id', 'label_id')
.withPivot('sort_order')
.query('orderBy', 'sort_order', 'ASC');
},
emitChange: function emitChange(event, options) {
const eventToTrigger = 'member' + '.' + event;
ghostBookshelf.Model.prototype.emitChange.bind(this)(this, eventToTrigger, options);
@ -32,6 +46,108 @@ const Member = ghostBookshelf.Model.extend({
ghostBookshelf.Model.prototype.onDestroyed.apply(this, arguments);
model.emitChange('deleted', options);
},
onDestroying: function onDestroyed(model) {
ghostBookshelf.Model.prototype.onDestroying.apply(this, arguments);
this.handleAttachedModels(model);
},
onSaving: function onSaving(model, attr, options) {
let labelsToSave;
let ops = [];
// CASE: detect lowercase/uppercase label slugs
if (!_.isUndefined(this.get('labels')) && !_.isNull(this.get('labels'))) {
labelsToSave = [];
// and deduplicate upper/lowercase tags
_.each(this.get('labels'), function each(item) {
item.name = item.name && item.name.trim();
for (let i = 0; i < labelsToSave.length; i = i + 1) {
if (labelsToSave[i].name && item.name && labelsToSave[i].name.toLocaleLowerCase() === item.name.toLocaleLowerCase()) {
return;
}
}
labelsToSave.push(item);
});
this.set('labels', labelsToSave);
}
// CASE: Detect existing labels with same case-insensitive name and replace
ops.push(function updateLabels() {
return ghostBookshelf.model('Label')
.findAll(Object.assign({
columns: ['id', 'name']
}, _.pick(options, 'transacting')))
.then((labels) => {
labelsToSave.forEach((label) => {
let existingLabel = labels.find((lab) => {
return label.name.toLowerCase() === lab.get('name').toLowerCase();
});
label.name = (existingLabel && existingLabel.get('name')) || label.name;
});
model.set('labels', labelsToSave);
});
});
this.handleAttachedModels(model);
return sequence(ops);
},
handleAttachedModels: function handleAttachedModels(model) {
/**
* @NOTE:
* Bookshelf only exposes the object that is being detached on `detaching`.
* For the reason above, `detached` handler is using the scope of `detaching`
* to access the models that are not present in `detached`.
*/
model.related('labels').once('detaching', function onDetached(collection, label) {
model.related('labels').once('detached', function onDetached(detachedCollection, response, options) {
label.emitChange('detached', options);
model.emitChange('label.detached', options);
});
});
model.related('labels').once('attaching', function onDetached(collection, labels) {
model.related('labels').once('attached', function onDetached(detachedCollection, response, options) {
labels.forEach((label) => {
label.emitChange('attached', options);
model.emitChange('label.attached', options);
});
});
});
},
/**
* The base model keeps only the columns, which are defined in the schema.
* We have to add the relations on top, otherwise bookshelf-relations
* has no access to the nested relations, which should be updated.
*/
permittedAttributes: function permittedAttributes() {
let filteredKeys = ghostBookshelf.Model.prototype.permittedAttributes.apply(this, arguments);
this.relationships.forEach((key) => {
filteredKeys.push(key);
});
return filteredKeys;
},
/**
* We have to ensure consistency. If you listen on model events (e.g. `member.added`), you can expect that you always
* receive all fields including relations. Otherwise you can't rely on a consistent flow. And we want to avoid
* that event listeners have to re-fetch a resource. This function is used in the context of inserting
* and updating resources. We won't return the relations by default for now.
*/
defaultRelations: function defaultRelations(methodName, options) {
if (['edit', 'add', 'destroy'].indexOf(methodName) !== -1) {
options.withRelated = _.union(['labels'], options.withRelated || []);
}
return options;
}
});

View File

@ -10,6 +10,12 @@ Array.prototype.forEach.call(document.querySelectorAll('form[data-members-form]'
var input = event.target.querySelector('input[data-members-email]');
var email = input.value;
var emailType = undefined;
var labels = [];
var labelInputs = event.target.querySelectorAll('input[data-members-label]') || [];
for (var i = 0;i < labelInputs.length; ++i) {
labels.push(labelInputs[i].value);
}
if (form.dataset.membersForm) {
emailType = form.dataset.membersForm;
@ -23,7 +29,8 @@ Array.prototype.forEach.call(document.querySelectorAll('form[data-members-form]'
},
body: JSON.stringify({
email: email,
emailType: emailType
emailType: emailType,
labels: labels
})
}).then(function (res) {
form.addEventListener('submit', submitHandler);

View File

@ -384,6 +384,9 @@
"tags": {
"tagNotFound": "Tag not found."
},
"labels": {
"labelNotFound": "Label not found."
},
"themes": {
"noPermissionToBrowseThemes": "You do not have permission to browse themes.",
"noPermissionToEditThemes": "You do not have permission to edit themes.",

View File

@ -18,6 +18,7 @@ const notImplemented = function (req, res, next) {
// @NOTE: experimental
actions: ['GET'],
tags: ['GET', 'PUT', 'DELETE', 'POST'],
labels: ['GET', 'PUT', 'DELETE', 'POST'],
users: ['GET'],
themes: ['POST', 'PUT'],
members: ['GET', 'PUT', 'DELETE', 'POST'],

View File

@ -100,6 +100,14 @@ module.exports = function apiRoutes() {
router.put('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiCanary.members.edit));
router.del('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiCanary.members.destroy));
// ## Labels
router.get('/labels', mw.authAdminApi, http(apiCanary.labels.browse));
router.get('/labels/:id', mw.authAdminApi, http(apiCanary.labels.read));
router.get('/labels/slug/:slug', mw.authAdminApi, http(apiCanary.labels.read));
router.post('/labels', mw.authAdminApi, http(apiCanary.labels.add));
router.put('/labels/:id', mw.authAdminApi, http(apiCanary.labels.edit));
router.del('/labels/:id', mw.authAdminApi, http(apiCanary.labels.destroy));
// ## Roles
router.get('/roles/', mw.authAdminApi, http(apiCanary.roles.browse));

View File

@ -55,7 +55,7 @@ describe('DB API', function () {
const jsonResponse = res.body;
should.exist(jsonResponse.db);
jsonResponse.db.should.have.length(1);
Object.keys(jsonResponse.db[0].data).length.should.eql(28);
Object.keys(jsonResponse.db[0].data).length.should.eql(30);
});
});

View File

@ -150,19 +150,19 @@ describe('Migration Fixture Utils', function () {
fixtureUtils.addFixturesForRelation(fixtures.relations[0]).then(function (result) {
should.exist(result);
result.should.be.an.Object();
result.should.have.property('expected', 66);
result.should.have.property('done', 66);
result.should.have.property('expected', 68);
result.should.have.property('done', 68);
// Permissions & Roles
permsAllStub.calledOnce.should.be.true();
rolesAllStub.calledOnce.should.be.true();
dataMethodStub.filter.callCount.should.eql(66);
dataMethodStub.filter.callCount.should.eql(68);
dataMethodStub.find.callCount.should.eql(7);
baseUtilAttachStub.callCount.should.eql(66);
baseUtilAttachStub.callCount.should.eql(68);
fromItem.related.callCount.should.eql(66);
fromItem.findWhere.callCount.should.eql(66);
toItem[0].get.callCount.should.eql(132);
fromItem.related.callCount.should.eql(68);
fromItem.findWhere.callCount.should.eql(68);
toItem[0].get.callCount.should.eql(136);
done();
}).catch(done);

View File

@ -19,8 +19,8 @@ var should = require('should'),
*/
describe('DB version integrity', function () {
// Only these variables should need updating
const currentSchemaHash = '3ec33e7039a21dba597ada2a03de0526';
const currentFixturesHash = 'ec8b7b480781b49070e04f94a0ec104f';
const currentSchemaHash = '07054c5c99b34da68c362f7431dedba4';
const currentFixturesHash = '4d4068c3edea019f3ed5d66eaa89feeb';
// If this test is failing, then it is likely a change has been made that requires a DB version bump,
// and the values above will need updating as confirmation

View File

@ -42,7 +42,7 @@
"@nexes/nql": "0.3.0",
"@sentry/node": "5.12.3",
"@tryghost/helpers": "1.1.22",
"@tryghost/members-api": "0.14.2",
"@tryghost/members-api": "0.15.0",
"@tryghost/members-ssr": "0.7.4",
"@tryghost/social-urls": "0.1.5",
"@tryghost/string": "^0.1.3",

View File

@ -306,22 +306,22 @@
dependencies:
"@tryghost/kg-clean-basic-html" "^0.1.5"
"@tryghost/magic-link@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@tryghost/magic-link/-/magic-link-0.4.0.tgz#f992ef766c09011fdb003dbb7844667b0b6a4d4a"
integrity sha512-Ef69RF+RDfALX1fZyU19hEqoXwiKxl7TAHuIIjdp2WuEXtvF5o4lviRvmGB6Ge4Ou6LWDKh7qJea/PK/eIVNRQ==
"@tryghost/magic-link@^0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@tryghost/magic-link/-/magic-link-0.4.1.tgz#2abdab02538f8b8adbe1940b4fd2479510480e26"
integrity sha512-KprbecOkSK0iB4Q0Eav9R5fYF+KO0YQRcOoXdw+Vd77bx36vU15PzOVqO1lg931s172KVWpEhQoh6t5n58QrkQ==
dependencies:
bluebird "^3.5.5"
ghost-ignition "^3.1.0"
jsonwebtoken "^8.5.1"
lodash "^4.17.15"
"@tryghost/members-api@0.14.2":
version "0.14.2"
resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-0.14.2.tgz#48804dcd0887a095f0debdb0a1d18a20efab1811"
integrity sha512-mKAx6pIiFJQbugyxDKsh1s7/8U5DZoJXsJQRy4V2ztv2lwLuLwmrs6yk2ZzZ6NVU8VUnextxjftHeY53rpNp2Q==
"@tryghost/members-api@0.15.0":
version "0.15.0"
resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-0.15.0.tgz#ca33bf0d7529e42f5a8f03cb788f4dff69c56a82"
integrity sha512-y/Rt81Z57SLm0106rLtDqps4eMUlcjwZvHBDu2BT/VgkEOXSwqWN+OejdvkQFu4e+VIit7DppE64UyMwomlYwg==
dependencies:
"@tryghost/magic-link" "^0.4.0"
"@tryghost/magic-link" "^0.4.1"
bluebird "^3.5.4"
body-parser "^1.19.0"
cookies "^0.8.0"