diff --git a/ghost/core/core/server/services/offers/application/offer-mapper.js b/ghost/core/core/server/services/offers/application/offer-mapper.js index e567d0a85ae..f412ba2ed53 100644 --- a/ghost/core/core/server/services/offers/application/offer-mapper.js +++ b/ghost/core/core/server/services/offers/application/offer-mapper.js @@ -33,6 +33,29 @@ * @prop {string|null} last_redeemed */ +/** + * @typedef {object} PublicOfferDTO + * @prop {string} id + * + * @prop {string} display_title + * @prop {string} display_description + * + * @prop {'percent'|'fixed'|'trial'} type + * + * @prop {'month'|'year'} cadence + * @prop {number} amount + * + * @prop {'once'|'repeating'|'forever'|'trial'} duration + * @prop {null|number} duration_in_months + * + * @prop {string|null} currency + * + * @prop {'active'|'archived'} status + * @prop {'signup'|'retention'} redemption_type + * + * @prop {{id: string}|null} tier + */ + class OfferMapper { /** * @param {Offer} offer @@ -62,6 +85,31 @@ class OfferMapper { last_redeemed: offer.lastRedeemed }; } + + /** + * Returns a DTO for a public facing offer (e.g. Portal's retention offer UI) + * + * @param {Offer} offer + * @returns {PublicOfferDTO} + */ + static toPublicDTO(offer) { + return { + id: offer.id, + display_title: offer.displayTitle.value, + display_description: offer.displayDescription.value, + type: offer.type.value, + cadence: offer.cadence.value, + amount: offer.amount.value, + duration: offer.duration.value.type, + duration_in_months: offer.duration.value.type === 'repeating' ? offer.duration.value.months : null, + currency: offer.type.value === 'fixed' ? offer.currency.value : null, + status: offer.status.value, + redemption_type: offer.redemptionType.value, + tier: offer.tier + ? {id: offer.tier.id} + : null + }; + } } module.exports = OfferMapper; diff --git a/ghost/core/core/server/services/offers/application/offers-api.js b/ghost/core/core/server/services/offers/application/offers-api.js index 5fc99cff6da..56c33853329 100644 --- a/ghost/core/core/server/services/offers/application/offers-api.js +++ b/ghost/core/core/server/services/offers/application/offers-api.js @@ -180,7 +180,7 @@ class OffersAPI { * @param {string} options.tierId * @param {'month'|'year'} options.cadence * @param {'signup'|'retention'} [options.redemptionType] - * @returns {Promise} + * @returns {Promise} */ async listOffersAvailableToSubscription({subscriptionId, tierId, cadence, redemptionType}) { debug(`listOffersAvailableToSubscription: subscriptionId=${subscriptionId}, tierId=${tierId}, cadence=${cadence}, redemptionType=${redemptionType}`); @@ -246,7 +246,7 @@ class OffersAPI { } debug(`listOffersAvailableToSubscription: returning ${available.length} available offers`); - return available.map(OfferMapper.toDTO); + return available.map(OfferMapper.toPublicDTO); }); } diff --git a/ghost/core/test/e2e-api/members/member-offers.test.js b/ghost/core/test/e2e-api/members/member-offers.test.js index 581ae557f3e..24f6b92db2a 100644 --- a/ghost/core/test/e2e-api/members/member-offers.test.js +++ b/ghost/core/test/e2e-api/members/member-offers.test.js @@ -135,8 +135,6 @@ describe('Members API - Member Offers', function () { assert.equal(body.offers.length, 1); assert.equal(body.offers[0].id, offer.id); - assert.equal(body.offers[0].name, 'Test Retention Offer'); - assert.equal(body.offers[0].code, 'test-retention'); assert.equal(body.offers[0].display_title, '20% off for 3 months'); assert.equal(body.offers[0].display_description, 'Stay with us!'); assert.equal(body.offers[0].type, 'percent'); @@ -145,6 +143,14 @@ describe('Members API - Member Offers', function () { assert.equal(body.offers[0].duration_in_months, 3); assert.equal(body.offers[0].cadence, cadence); assert.equal(body.offers[0].redemption_type, 'retention'); + + // Ensure only public facing fields are returned + assert.equal(body.offers[0].name, undefined); + assert.equal(body.offers[0].code, undefined); + assert.equal(body.offers[0].currency_restriction, undefined); + assert.equal(body.offers[0].redemption_count, undefined); + assert.equal(body.offers[0].created_at, undefined); + assert.equal(body.offers[0].last_redeemed, undefined); } finally { // Clean up await models.Offer.destroy({id: offer.id}); diff --git a/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js b/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js index e71cbe3fd65..00b622cf290 100644 --- a/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js @@ -1620,7 +1620,7 @@ describe('RouterController', function () { }); it('returns offers when subscription has an expired discount', async function () { - const mockOffer = {id: 'retention_offer', name: 'Stay with us'}; + const mockOffer = {id: 'retention_offer'}; mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]); const pastDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); @@ -1641,7 +1641,7 @@ describe('RouterController', function () { }); it('returns offers when subscription has expired once offer (legacy data, no discount_start)', async function () { - const mockOffer = {id: 'retention_offer', name: 'Stay with us'}; + const mockOffer = {id: 'retention_offer'}; mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]); mockOffersAPI.getOffer.resolves({duration: 'once'}); @@ -1693,7 +1693,7 @@ describe('RouterController', function () { }); it('returns offers when subscription trial has ended', async function () { - const mockOffer = {id: 'offer_123', name: 'Test Offer'}; + const mockOffer = {id: 'offer_123'}; mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]); const pastDate = new Date(); @@ -1712,7 +1712,7 @@ describe('RouterController', function () { }); it('returns offers when subscription has no trial period', async function () { - const mockOffer = {id: 'offer_123', name: 'Test Offer'}; + const mockOffer = {id: 'offer_123'}; mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]); const routerController = createRouterController({ diff --git a/ghost/core/test/unit/server/services/offers/application/offers-api.test.js b/ghost/core/test/unit/server/services/offers/application/offers-api.test.js index fc664d49054..bb80f4b94b5 100644 --- a/ghost/core/test/unit/server/services/offers/application/offers-api.test.js +++ b/ghost/core/test/unit/server/services/offers/application/offers-api.test.js @@ -91,6 +91,56 @@ describe('OffersAPI', function () { assert.equal(result[0].id, 'offer-1'); }); + it('returns public facing offer DTO', async function () { + const tierId = new ObjectID().toHexString(); + const offers = [ + createMockOffer('offer-1', {tierId, cadence: 'month', redemptionType: 'retention'}) + ]; + + const repository = createMockRepository({ + getAll: sinon.stub().resolves(offers), + getRedeemedOfferIdsForSubscription: sinon.stub().resolves([]) + }); + + const api = new OffersAPI(/** @type {OfferBookshelfRepository} */ (/** @type {unknown} */ (repository))); + + const result = await api.listOffersAvailableToSubscription({ + subscriptionId: 'sub_123', + tierId, + cadence: 'month', + redemptionType: 'retention' + }); + + assert.equal(result.length, 1); + + const keys = Object.keys(result[0]); + + assert.deepEqual(keys.sort(), [ + 'amount', + 'cadence', + 'currency', + 'display_description', + 'display_title', + 'duration', + 'duration_in_months', + 'id', + 'redemption_type', + 'status', + 'tier', + 'type' + ]); + + assert.equal(result[0].name, undefined); + assert.equal(result[0].code, undefined); + assert.equal(result[0].currency_restriction, undefined); + assert.equal(result[0].redemption_count, undefined); + assert.equal(result[0].created_at, undefined); + assert.equal(result[0].last_redeemed, undefined); + + assert.deepEqual(Object.keys(result[0].tier).sort(), ['id']); + assert.equal(result[0].tier.name, undefined); + }); + it('excludes trial offers', async function () { const tierId = new ObjectID().toHexString(); const offers = [