mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-03 00:15:11 +03:00
✨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:
parent
aff289bfee
commit
001db05075
@ -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);
|
||||
},
|
||||
|
145
core/server/api/canary/labels.js
Normal file
145
core/server/api/canary/labels.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
@ -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: {
|
||||
|
@ -25,5 +25,9 @@ module.exports = {
|
||||
|
||||
get tags() {
|
||||
return require('./tags');
|
||||
},
|
||||
|
||||
get members() {
|
||||
return require('./members');
|
||||
}
|
||||
};
|
||||
|
46
core/server/api/canary/utils/serializers/input/members.js
Normal file
46
core/server/api/canary/utils/serializers/input/members.js
Normal 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);
|
||||
}
|
||||
};
|
@ -109,5 +109,9 @@ module.exports = {
|
||||
|
||||
get emails() {
|
||||
return require('./emails');
|
||||
},
|
||||
|
||||
get labels() {
|
||||
return require('./labels');
|
||||
}
|
||||
};
|
||||
|
25
core/server/api/canary/utils/serializers/output/labels.js
Normal file
25
core/server/api/canary/utils/serializers/output/labels.js
Normal 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)]
|
||||
};
|
||||
}
|
||||
};
|
@ -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
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
|
@ -35,6 +35,10 @@ module.exports = {
|
||||
return require('./tags');
|
||||
},
|
||||
|
||||
get labels() {
|
||||
return require('./labels');
|
||||
},
|
||||
|
||||
get users() {
|
||||
return require('./users');
|
||||
},
|
||||
|
15
core/server/api/canary/utils/validators/input/labels.js
Normal file
15
core/server/api/canary/utils/validators/input/labels.js
Normal 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);
|
||||
}
|
||||
};
|
@ -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" ]
|
||||
}
|
@ -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" ]
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -37,6 +37,9 @@
|
||||
"email": {
|
||||
"strip": true
|
||||
},
|
||||
"labels": {
|
||||
"$ref": "members#/definitions/member-labels"
|
||||
},
|
||||
"created_at": {
|
||||
"strip": true
|
||||
},
|
||||
@ -54,4 +57,4 @@
|
||||
}
|
||||
},
|
||||
"required": ["members"]
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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`));
|
||||
});
|
||||
};
|
@ -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"
|
||||
},
|
||||
|
@ -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},
|
||||
|
@ -913,6 +913,8 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
|
||||
data: data,
|
||||
meta: {pagination: response.pagination}
|
||||
};
|
||||
}).catch((err) => {
|
||||
throw err;
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -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
126
core/server/models/label.js
Normal 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)
|
||||
};
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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.",
|
||||
|
@ -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'],
|
||||
|
@ -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));
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
18
yarn.lock
18
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user