diff --git a/ghost/core/core/server/models/member.js b/ghost/core/core/server/models/member.js index f1069307fb..398eeb06dd 100644 --- a/ghost/core/core/server/models/member.js +++ b/ghost/core/core/server/models/member.js @@ -3,6 +3,7 @@ const uuid = require('uuid'); const _ = require('lodash'); const config = require('../../shared/config'); const {gravatar} = require('../lib/image'); +const labs = require('../../shared/labs'); const Member = ghostBookshelf.Model.extend({ tableName: 'members', @@ -102,12 +103,16 @@ const Member = ghostBookshelf.Model.extend({ products() { return this.belongsToMany('Product', 'members_products', 'member_id', 'product_id') - .withPivot('sort_order') + .withPivot('sort_order', 'expiry_at') .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.*'); + if (labs.isSet('compExpiring')) { + qb.columns('products.*', 'expiry_at'); + } else { + qb.columns('products.*'); + } }); }, @@ -155,6 +160,21 @@ const Member = ghostBookshelf.Model.extend({ return this.hasMany('EmailRecipient', 'member_id', 'id'); }, + async updateTierExpiry(products = [], options = {}) { + if (!labs.isSet('compExpiring')) { + return; + } + for (const product of products) { + if (product?.expiry_at) { + const expiry = new Date(product.expiry_at); + const queryOptions = _.extend({}, options, { + query: {where: {product_id: product.id}} + }); + await this.products().updatePivot({expiry_at: expiry}, queryOptions); + } + } + }, + serialize(options) { const defaultSerializedObject = ghostBookshelf.Model.prototype.serialize.call(this, options); @@ -344,7 +364,13 @@ const Member = ghostBookshelf.Model.extend({ return this.add(data, Object.assign({transacting}, unfilteredOptions)); }); } - return ghostBookshelf.Model.add.call(this, data, unfilteredOptions); + + return ghostBookshelf.Model.add.call(this, data, unfilteredOptions).then(async (member) => { + if (data.products) { + await member.updateTierExpiry(data.products, _.pick(unfilteredOptions, 'transacting')); + } + return member; + }); }, edit(data, unfilteredOptions = {}) { @@ -353,7 +379,13 @@ const Member = ghostBookshelf.Model.extend({ return this.edit(data, Object.assign({transacting}, unfilteredOptions)); }); } - return ghostBookshelf.Model.edit.call(this, data, unfilteredOptions); + + return ghostBookshelf.Model.edit.call(this, data, unfilteredOptions).then(async (member) => { + if (data.products) { + await member.updateTierExpiry(data.products, _.pick(unfilteredOptions, 'transacting')); + } + return member; + }); }, destroy(unfilteredOptions = {}) { diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members-newsletters.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members-newsletters.test.js.snap index 66730c53ff..30fd31f0bc 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members-newsletters.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members-newsletters.test.js.snap @@ -63,7 +63,7 @@ exports[`Members API - With Newsletters - compat mode Can fetch members who are Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2071", + "content-length": "2088", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", @@ -218,7 +218,7 @@ exports[`Members API - With Newsletters - compat mode Can fetch members who are Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "11646", + "content-length": "11697", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", @@ -289,7 +289,7 @@ exports[`Members API - With Newsletters Can fetch members who are NOT subscribed Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2071", + "content-length": "2088", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", @@ -444,7 +444,7 @@ exports[`Members API - With Newsletters Can fetch members who are subscribed 2: Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "11646", + "content-length": "11697", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap index 9c2e6d4835..fc4d3aa60a 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap @@ -905,6 +905,7 @@ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "description": null, + "expiry_at": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "monthly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "name": "Default Product", @@ -928,7 +929,7 @@ exports[`Members API Can add complimentary subscription (out of date) 4: [header Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2583", + "content-length": "2617", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", @@ -1125,7 +1126,7 @@ exports[`Members API Can browse 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "13616", + "content-length": "13684", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", @@ -1286,6 +1287,7 @@ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "description": null, + "expiry_at": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "monthly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "name": "Default Product", @@ -1309,7 +1311,7 @@ exports[`Members API Can create a member with an existing complimentary subscrip Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2634", + "content-length": "2668", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -1378,7 +1380,7 @@ exports[`Members API Can create a member with an existing paid subscription 2: [ Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2620", + "content-length": "2654", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -1440,6 +1442,7 @@ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "description": null, + "expiry_at": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "monthly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "name": "Default Product", @@ -1463,7 +1466,7 @@ exports[`Members API Can create a new member with a product (complementary) 2: [ Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2410", + "content-length": "2444", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -1826,7 +1829,7 @@ exports[`Members API Can filter by paid status 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "9935", + "content-length": "10003", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", @@ -1939,7 +1942,7 @@ exports[`Members API Can filter on newsletter slug 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "8642", + "content-length": "8676", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", @@ -2144,7 +2147,7 @@ exports[`Members API Can filter on tier slug 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "20695", + "content-length": "20967", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", @@ -2328,7 +2331,7 @@ exports[`Members API Can ignore any unknown includes 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "9935", + "content-length": "10003", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", @@ -3494,7 +3497,7 @@ exports[`Members API Search for paid members retrieves member with email paid@te Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2486", + "content-length": "2503", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", diff --git a/ghost/core/test/unit/server/models/member.test.js b/ghost/core/test/unit/server/models/member.test.js index 2c8ac79a36..39aa548c06 100644 --- a/ghost/core/test/unit/server/models/member.test.js +++ b/ghost/core/test/unit/server/models/member.test.js @@ -1,6 +1,8 @@ const sinon = require('sinon'); +const should = require('should'); const models = require('../../../../core/server/models'); const configUtils = require('../../../utils/configUtils'); +const labs = require('../../../../core/shared/labs'); const config = configUtils.config; @@ -49,4 +51,31 @@ describe('Unit: models/member', function () { should(json.avatar_image).eql(null); }); }); + + describe('updateTierExpiry', function () { + let memberModel; + let updatePivot; + + beforeEach(function () { + memberModel = new models.Member({email: 'text@example.com'}); + updatePivot = sinon.stub(); + + sinon.stub(memberModel, 'products').callsFake(() => { + return { + updatePivot: updatePivot + }; + }); + sinon.stub(labs, 'isSet').returns(true); + }); + + it('calls updatePivot on member products to set expiry', function () { + const expiry = (new Date()).toISOString(); + memberModel.updateTierExpiry([{ + expiry_at: expiry, + id: '1' + }]); + + updatePivot.calledWith({expiry_at: new Date(expiry)}, {query: {where: {product_id: '1'}}}).should.be.true(); + }); + }); });