From 15b7485a9416452c210cac3f4135f3020d9373dd Mon Sep 17 00:00:00 2001 From: Fabien 'egg' O'Carroll Date: Thu, 8 Apr 2021 18:01:49 +0100 Subject: [PATCH] Added Product model and Member model relation (#12859) refs https://github.com/TryGhost/Team/issues/586 - Member model now has `products` relation, sorted using `sort_order`, following convention from `labels` - Product model has handling to set `slug` from name, following convention of Label model - Updated filter plugin to handle filtering Member models by their `product` relations e.g. `product:[slug, slug]` --- core/server/models/index.js | 1 + core/server/models/member.js | 14 +++- core/server/models/plugins/filter.js | 13 ++++ core/server/models/product.js | 40 ++++++++++ test/regression/models/model_members_spec.js | 80 ++++++++++++++++++++ 5 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 core/server/models/product.js diff --git a/core/server/models/index.js b/core/server/models/index.js index dd2f4809af..4e724a38fe 100644 --- a/core/server/models/index.js +++ b/core/server/models/index.js @@ -28,6 +28,7 @@ const models = [ 'api-key', 'mobiledoc-revision', 'member', + 'product', 'member-subscribe-event', 'member-paid-subscription-event', 'member-login-event', diff --git a/core/server/models/member.js b/core/server/models/member.js index d96846a69e..7fb7b84f78 100644 --- a/core/server/models/member.js +++ b/core/server/models/member.js @@ -17,7 +17,7 @@ const Member = ghostBookshelf.Model.extend({ }; }, - relationships: ['labels', 'stripeCustomers', 'email_recipients'], + relationships: ['products', 'labels', 'stripeCustomers', 'email_recipients'], // do not delete email_recipients records when a member is destroyed. Recipient // records are used for analytics and historical records @@ -28,11 +28,23 @@ const Member = ghostBookshelf.Model.extend({ }, relationshipBelongsTo: { + products: 'products', labels: 'labels', stripeCustomers: 'members_stripe_customers', email_recipients: 'email_recipients' }, + products() { + return this.belongsToMany('Product', 'members_products', 'member_id', 'product_id') + .withPivot('sort_order') + .query('orderBy', 'sort_order', 'ASC') + .query((qb) => { + // avoids bookshelf adding a `DISTINCT` to the query + // we know the result set will already be unique and DISTINCT hurts query performance + qb.columns('products.*'); + }); + }, + labels: function labels() { return this.belongsToMany('Label', 'members_labels', 'member_id', 'label_id') .withPivot('sort_order') diff --git a/core/server/models/plugins/filter.js b/core/server/models/plugins/filter.js index 6e34b1294c..b288b4118a 100644 --- a/core/server/models/plugins/filter.js +++ b/core/server/models/plugins/filter.js @@ -25,6 +25,13 @@ const RELATIONS = { joinFrom: 'member_id', joinTo: 'label_id' }, + products: { + tableName: 'products', + type: 'manyToMany', + joinTable: 'members_products', + joinFrom: 'member_id', + joinTo: 'product_id' + }, posts_meta: { tableName: 'posts_meta', type: 'oneToOne', @@ -60,6 +67,12 @@ const EXPANSIONS = { }, { key: 'labels', replacement: 'labels.slug' + }, { + key: 'product', + replacement: 'products.slug' + }, { + key: 'products', + replacement: 'products.slug' }] }; diff --git a/core/server/models/product.js b/core/server/models/product.js new file mode 100644 index 0000000000..3fdcb42fcc --- /dev/null +++ b/core/server/models/product.js @@ -0,0 +1,40 @@ +const ghostBookshelf = require('./base'); + +const Product = ghostBookshelf.Model.extend({ + tableName: 'products', + + async onSaving(model, _attr, options) { + ghostBookshelf.Model.prototype.onSaving.apply(this, arguments); + + if (model.get('name')) { + model.set('name', model.get('name').trim()); + } + + if (model.hasChanged('slug') || !model.get('slug')) { + const slug = model.get('slug') || model.get('name'); + + if (!slug) { + return; + } + + const cleanSlug = await ghostBookshelf.Model.generateSlug(Product, slug, { + transacting: options.transacting + }); + + return model.set({slug: cleanSlug}); + } + }, + + members: function members() { + return this.belongsToMany('Member', 'members_products', 'product_id', 'member_id'); + } +}); + +const Products = ghostBookshelf.Collection.extend({ + model: Product +}); + +module.exports = { + Product: ghostBookshelf.model('Product', Product), + Products: ghostBookshelf.collection('Products', Products) +}; diff --git a/test/regression/models/model_members_spec.js b/test/regression/models/model_members_spec.js index a1436a9938..7d900d7da4 100644 --- a/test/regression/models/model_members_spec.js +++ b/test/regression/models/model_members_spec.js @@ -1,6 +1,7 @@ const should = require('should'); const BaseModel = require('../../../core/server/models/base'); const {Label} = require('../../../core/server/models/label'); +const {Product} = require('../../../core/server/models/product'); const {Member} = require('../../../core/server/models/member'); const {MemberStripeCustomer} = require('../../../core/server/models/member-stripe-customer'); const {StripeCustomerSubscription} = require('../../../core/server/models/stripe-customer-subscription'); @@ -218,5 +219,84 @@ describe('Member Model', function run() { }).catch(done); }); }); + + describe('products', function () { + it('Products can be created & added to members by the product array', async function () { + const context = testUtils.context.admin; + const product = await Product.add({ + name: 'Product-Add-Test' + }); + const member = await Member.add({ + email: 'testing-products@test.member', + products: [{ + id: product.id + }, { + name: 'Product-Create-Test' + }] + }, { + ...context, + withRelated: ['products'] + }); + + const createdProduct = await Product.findOne({ + name: 'Product-Create-Test' + }, context); + + should.exist(createdProduct, 'Product should have been created'); + + const products = member.related('products').toJSON(); + + should.exist( + products.find(model => model.name === 'Product-Create-Test') + ); + + should.exist( + products.find(model => model.name === 'Product-Add-Test') + ); + }); + }); + + describe('Filtering on products', function () { + it('Should allow filtering on products', async function () { + const context = testUtils.context.admin; + + await Member.add({ + email: 'filter-test@test.member', + products: [{ + name: 'VIP', + slug: 'vip' + }] + }, context); + + const member = await Member.findOne({ + email: 'filter-test@test.member' + }, context); + + should.exist(member, 'Member should have been created'); + + const product = await Product.findOne({ + slug: 'vip' + }, context); + + should.exist(product, 'Product should have been created'); + + const memberProduct = await BaseModel.knex('members_products').where({ + product_id: product.get('id'), + member_id: member.get('id') + }).select().first(); + + should.exist(memberProduct, 'Product should have been attached to member'); + + const vipProductMembers = await Member.findPage({filter: 'products:vip'}); + const foundMemberInVIP = vipProductMembers.data.find(model => model.id === member.id); + + should.exist(foundMemberInVIP, 'Member should have been included in products filter'); + + const podcastProductMembers = await Member.findPage({filter: 'products:podcast'}); + const foundMemberInPodcast = podcastProductMembers.data.find(model => model.id === member.id); + + should.not.exist(foundMemberInPodcast, 'Member should not have been included in products filter'); + }); + }); });