diff --git a/ghost/admin/app/helpers/parse-member-event.js b/ghost/admin/app/helpers/parse-member-event.js index 11407c6e9fd..91a9ddcc22f 100644 --- a/ghost/admin/app/helpers/parse-member-event.js +++ b/ghost/admin/app/helpers/parse-member-event.js @@ -148,6 +148,10 @@ export default class ParseMemberEventHelper extends Helper { icon = 'gift'; } + if (event.type === 'gift_ended_event') { + icon = 'subscriptions'; + } + if (event.type === 'email_change_event') { icon = 'email-changed'; } @@ -279,6 +283,10 @@ export default class ParseMemberEventHelper extends Helper { if (event.type === 'gift_redemption_event') { return 'started paid subscription via gift'; } + + if (event.type === 'gift_ended_event') { + return 'ended paid subscription'; + } } /** diff --git a/ghost/admin/app/utils/member-event-types.js b/ghost/admin/app/utils/member-event-types.js index 1f20b32a200..94db184f3f6 100644 --- a/ghost/admin/app/utils/member-event-types.js +++ b/ghost/admin/app/utils/member-event-types.js @@ -37,9 +37,11 @@ export function toggleEventType(eventType, currentExcludedEvents = []) { if (excludedEvents.has('subscription_event')) { excludedEvents.delete('subscription_event'); excludedEvents.delete('gift_redemption_event'); + excludedEvents.delete('gift_ended_event'); } else { excludedEvents.add('subscription_event'); excludedEvents.add('gift_redemption_event'); + excludedEvents.add('gift_ended_event'); } } else if (eventType === 'payment_event') { if (excludedEvents.has('payment_event')) { diff --git a/ghost/admin/tests/unit/helpers/parse-member-event-test.js b/ghost/admin/tests/unit/helpers/parse-member-event-test.js index dd32e73b645..cef76c440b3 100644 --- a/ghost/admin/tests/unit/helpers/parse-member-event-test.js +++ b/ghost/admin/tests/unit/helpers/parse-member-event-test.js @@ -113,4 +113,18 @@ describe('Unit: Helper: parse-member-event', function () { expect(result.info).to.equal('Free'); }); }); + + describe('gift_ended_event', function () { + it('returns "ended paid subscription" action', function () { + const event = buildEvent({type: 'gift_ended_event'}); + const result = helper.compute([event]); + expect(result.action).to.equal('ended paid subscription'); + }); + + it('returns "event-subscriptions" icon', function () { + const event = buildEvent({type: 'gift_ended_event'}); + const result = helper.compute([event]); + expect(result.icon).to.equal('event-subscriptions'); + }); + }); }); diff --git a/ghost/admin/tests/unit/utils/member-event-types-test.js b/ghost/admin/tests/unit/utils/member-event-types-test.js index 41b4a151df5..7f6f04e196b 100644 --- a/ghost/admin/tests/unit/utils/member-event-types-test.js +++ b/ghost/admin/tests/unit/utils/member-event-types-test.js @@ -29,14 +29,14 @@ describe('Unit | Utility | event-type-utils', function () { expect(newExcludedEvents).to.equal(''); }); - it('should toggle subscription_event together with gift_redemption_event', function () { + it('should toggle subscription_event together with gift_redemption_event and gift_ended_event', function () { const newExcludedEvents = toggleEventType('subscription_event', []); - expect(newExcludedEvents).to.equal('subscription_event,gift_redemption_event'); + expect(newExcludedEvents).to.equal('subscription_event,gift_redemption_event,gift_ended_event'); }); it('should toggle subscription_event group off when toggling subscription_event off', function () { - const newExcludedEvents = toggleEventType('subscription_event', ['subscription_event', 'gift_redemption_event']); + const newExcludedEvents = toggleEventType('subscription_event', ['subscription_event', 'gift_redemption_event', 'gift_ended_event']); expect(newExcludedEvents).to.equal(''); }); @@ -44,19 +44,19 @@ describe('Unit | Utility | event-type-utils', function () { it('should preserve previously-excluded payment group when toggling subscription_event', function () { const newExcludedEvents = toggleEventType('subscription_event', ['payment_event', 'donation_event', 'gift_purchase_event']); - expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,subscription_event,gift_redemption_event'); + expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,subscription_event,gift_redemption_event,gift_ended_event'); }); it('should preserve previously-excluded subscription group when toggling payment_event', function () { - const newExcludedEvents = toggleEventType('payment_event', ['subscription_event', 'gift_redemption_event']); + const newExcludedEvents = toggleEventType('payment_event', ['subscription_event', 'gift_redemption_event', 'gift_ended_event']); - expect(newExcludedEvents).to.equal('subscription_event,gift_redemption_event,payment_event,donation_event,gift_purchase_event'); + expect(newExcludedEvents).to.equal('subscription_event,gift_redemption_event,gift_ended_event,payment_event,donation_event,gift_purchase_event'); }); it('should accept a comma-separated string for currentExcludedEvents', function () { const newExcludedEvents = toggleEventType('subscription_event', 'payment_event,donation_event,gift_purchase_event'); - expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,subscription_event,gift_redemption_event'); + expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,subscription_event,gift_redemption_event,gift_ended_event'); }); it('should return correct divider need based on event groups', function () { diff --git a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js index ba9c67cbc15..5a82996a507 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js @@ -86,7 +86,8 @@ module.exports = class EventRepository { {type: 'payment_event', action: 'getPaymentEvents'}, {type: 'email_change_event', action: 'getEmailChangeEvent'}, {type: 'gift_purchase_event', action: 'getGiftPurchaseEvents'}, - {type: 'gift_redemption_event', action: 'getGiftRedemptionEvents'} + {type: 'gift_redemption_event', action: 'getGiftRedemptionEvents'}, + {type: 'gift_ended_event', action: 'getGiftEndedEvents'} ); if (this._AutomatedEmailRecipient) { @@ -136,6 +137,7 @@ module.exports = class EventRepository { login_event: 0, subscription_event: 1, gift_redemption_event: 1, + gift_ended_event: 1, newsletter_event: 2, signup_event: 3 }; @@ -554,6 +556,46 @@ module.exports = class EventRepository { }; } + async getGiftEndedEvents(options = {}, filter) { + options = { + ...options, + withRelated: ['member'], + filter: 'from_status:gift+to_status:free+custom:true', + useBasicCount: true, + mongoTransformer: chainTransformers( + // First set the filter manually + replaceCustomFilterTransformer(filter), + + // Map the used keys in that filter + ...mapKeys({ + 'data.created_at': 'created_at', + 'data.member_id': 'member_id' + }) + ) + }; + + const {data: models, meta} = await this._MemberStatusEvent.findPage(options); + + const data = models.map((model) => { + const json = model.toJSON(options); + + return { + type: 'gift_ended_event', + data: { + id: json.id, + member: json.member || null, + member_id: json.member_id, + created_at: json.created_at + } + }; + }); + + return { + data, + meta + }; + } + async getCommentEvents(options = {}, filter) { options = { ...options, diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js index 6242378d910..00671a001cd 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js @@ -658,4 +658,102 @@ describe('EventRepository', function () { assert.equal(event.data.member_id, null); }); }); + + describe('getGiftEndedEvents', function () { + let eventRepository; + let fake; + + before(function () { + fake = sinon.fake.returns({data: [{ + toJSON: () => ({ + id: 'status-event-1', + member_id: 'member-abc', + member: {id: 'member-abc', name: 'Test Member', email: 'member@example.com'}, + from_status: 'gift', + to_status: 'free', + created_at: '2024-10-15T08:00:00.000Z' + }) + }]}); + eventRepository = new EventRepository({ + EmailRecipient: null, + MemberSubscribeEvent: null, + MemberPaymentEvent: null, + MemberStatusEvent: { + findPage: fake + }, + MemberLoginEvent: null, + MemberPaidSubscriptionEvent: null, + labsService: null + }); + }); + + afterEach(function () { + fake.resetHistory(); + }); + + it('queries with correct options', async function () { + await eventRepository.getGiftEndedEvents({ + filter: 'not used', + order: 'created_at desc, id desc' + }, { + type: 'unused' + }); + + sinon.assert.calledOnceWithMatch(fake, { + withRelated: ['member'], + filter: 'from_status:gift+to_status:free+custom:true', + order: 'created_at desc, id desc' + }); + }); + + it('returns correctly formatted gift_ended_event', async function () { + const result = await eventRepository.getGiftEndedEvents({ + order: 'created_at desc, id desc' + }, {}); + + assert.equal(result.data.length, 1); + + const event = result.data[0]; + + assert.equal(event.type, 'gift_ended_event'); + assert.equal(event.data.id, 'status-event-1'); + assert.equal(event.data.member_id, 'member-abc'); + assert.equal(event.data.created_at, '2024-10-15T08:00:00.000Z'); + assert.deepEqual(event.data.member, { + id: 'member-abc', + name: 'Test Member', + email: 'member@example.com' + }); + }); + + it('sets member to null when member relation is not present', async function () { + const nullMemberFake = sinon.fake.returns({data: [{ + toJSON: () => ({ + id: 'status-event-2', + member_id: 'member-xyz', + member: null, + from_status: 'gift', + to_status: 'free', + created_at: '2024-11-01T12:00:00.000Z' + }) + }]}); + const repo = new EventRepository({ + EmailRecipient: null, + MemberSubscribeEvent: null, + MemberPaymentEvent: null, + MemberStatusEvent: { + findPage: nullMemberFake + }, + MemberLoginEvent: null, + MemberPaidSubscriptionEvent: null, + labsService: null + }); + + const result = await repo.getGiftEndedEvents({}, {}); + const event = result.data[0]; + + assert.equal(event.data.member, null); + assert.equal(event.data.member_id, 'member-xyz'); + }); + }); });