Skip to content

Fixed unwanted new free member notification when redeeming a gift#27460

Merged
sagzy merged 2 commits into
mainfrom
gift-subscriptions/do-not-send-new-free-member-notification
Apr 21, 2026
Merged

Fixed unwanted new free member notification when redeeming a gift#27460
sagzy merged 2 commits into
mainfrom
gift-subscriptions/do-not-send-new-free-member-notification

Conversation

@sagzy
Copy link
Copy Markdown
Contributor

@sagzy sagzy commented Apr 20, 2026

closes https://linear.app/ghost/issue/BER-3476

  • when a new member redeemed a gift subscription, they were briefly created with status 'free' and only flipped to 'gift' by the subsequent redeem call, which triggered the "new free signup" staff notification - now fixed
  • they would also receive the free member welcome email - now fixed
  • wrapped member creation and gift redemption in a transaction to ensure atomicity

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

Walkthrough

Gift redemption API changed from an object to a positional signature: GiftService.redeem(token, memberId, options?) with options supporting newMember and transacting. Redemption logic was refactored to centralize transactional behavior (use a provided transaction or create one), adjust redeemability checks for new members, and separate notification scheduling when using external transactions. Controller and API call sites were updated, member creation now accepts and validates an explicit status, and unit/E2E tests were updated accordingly.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: preventing unwanted free member notifications when redeeming a gift subscription.
Description check ✅ Passed The description is directly related to the changeset, explaining the bug fix, the issue being addressed, and the approach taken to fix it.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gift-subscriptions/do-not-send-new-free-member-notification

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ghost/core/core/server/services/members/members-api/repositories/member-repository.js (1)

316-350: ⚠️ Potential issue | 🟠 Major

Validate explicit member statuses before persisting.

Line 316 now accepts caller-provided data.status without validation, and Lines 344-350 skip the status inference logic whenever it is present. An invalid or misspelled status value (e.g., 'ompec' instead of 'comped') will be persisted without error. This bypasses repository invariants and affects automation gating at line 373, which checks memberData.status === 'free' to trigger welcome emails.

While member-bread-service.js validates comped and stripe_customer_id, it does not validate the status field before calling memberRepository.create(). Add explicit validation to ensure only allowed values ('free', 'paid', 'comped', 'gift') are accepted.

🛡️ Proposed guard
+const VALID_MEMBER_STATUSES = new Set(['free', 'paid', 'comped', 'gift']);
+
...
         const memberData = _.pick(data, ['email', 'name', 'note', 'subscribed', 'geolocation', 'created_at', 'products', 'newsletters', 'email_disabled', 'status']);
+
+        if (memberData.status && !VALID_MEMBER_STATUSES.has(memberData.status)) {
+            throw new errors.ValidationError({
+                message: 'Invalid member status',
+                property: 'status'
+            });
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/services/members/members-api/repositories/member-repository.js`
around lines 316 - 350, The member creation code currently accepts
caller-provided memberData.status without validation which allows invalid values
to be persisted; update the create flow in member-repository (the block that
sets memberData.transient_id and evaluates memberData.status) to validate any
explicit memberData.status against the allowed set
['free','paid','comped','gift'] and throw a ValidationError (similar to the
existing errors.ValidationError usage) if the value is not one of those; retain
the existing inference logic (setting status when memberData.status is not
present) so callers who omit status still get the correct default, and ensure
downstream checks (e.g., where memberData.status === 'free' triggers welcome
emails) can rely on this strict set.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js`:
- Around line 2097-2105: The test "respects the status passed in data instead of
overriding it" currently omits products so it won't catch regressions that infer
"comped" from products; update the call to buildRepo().create in that test to
include a non-empty products array (e.g., products: ['monthly'] or similar)
alongside email/name/status:'gift', then assert (using memberAdd and
memberAdd.firstCall.args[0].status) that the status remains 'gift' to ensure
explicit status is not overridden by product-based inference.

---

Outside diff comments:
In
`@ghost/core/core/server/services/members/members-api/repositories/member-repository.js`:
- Around line 316-350: The member creation code currently accepts
caller-provided memberData.status without validation which allows invalid values
to be persisted; update the create flow in member-repository (the block that
sets memberData.transient_id and evaluates memberData.status) to validate any
explicit memberData.status against the allowed set
['free','paid','comped','gift'] and throw a ValidationError (similar to the
existing errors.ValidationError usage) if the value is not one of those; retain
the existing inference logic (setting status when memberData.status is not
present) so callers who omit status still get the correct default, and ensure
downstream checks (e.g., where memberData.status === 'free' triggers welcome
emails) can rely on this strict set.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8047ba10-0139-43a8-b615-f889a3873913

📥 Commits

Reviewing files that changed from the base of the PR and between e7e19a5 and d09caf3.

📒 Files selected for processing (9)
  • ghost/core/core/server/services/gifts/gift-controller.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/core/server/services/members/members-api/repositories/member-repository.js
  • ghost/core/test/e2e-api/members/gift-subscriptions.test.js
  • ghost/core/test/unit/server/services/gifts/gift-controller.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
  • ghost/core/test/unit/server/services/members/members-api/members-api.test.js
  • ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js

@sagzy sagzy force-pushed the gift-subscriptions/do-not-send-new-free-member-notification branch from d09caf3 to b1942c2 Compare April 20, 2026 15:27
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ghost/core/core/server/services/members/members-api/members-api.js (1)

280-282: Consider applying the same transactional pattern to the existing-member redeem path.

The new-member path now wraps redemption in Member.transaction, but the existing-member branch at line 281 still calls giftService.service.redeem(giftToken, member.id) without a transacting option. For an existing member this doesn't cause the "free signup" notification, so it isn't a blocker for this PR's objective — but for consistency and atomicity of the redeem operation itself (subscription creation + member status update inside redeem), you may want to wrap this call in a transaction as well in a follow-up.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/members/members-api/members-api.js` around
lines 280 - 282, The existing-member branch currently calls
giftService.service.redeem(giftToken, member.id) outside a transaction; wrap
this call in Member.transaction similarly to the new-member path and pass the
transaction object into the redeem call (e.g., via a transacting option) so the
subscription creation and member updates inside giftService.service.redeem run
atomically; locate the call sites referencing giftService.service.redeem,
Member.transaction, giftToken, giftSubscriptionsEnabled and ensure redeem
accepts/receives the transacting option and uses it for DB operations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ghost/core/core/server/services/members/members-api/members-api.js`:
- Around line 280-282: The existing-member branch currently calls
giftService.service.redeem(giftToken, member.id) outside a transaction; wrap
this call in Member.transaction similarly to the new-member path and pass the
transaction object into the redeem call (e.g., via a transacting option) so the
subscription creation and member updates inside giftService.service.redeem run
atomically; locate the call sites referencing giftService.service.redeem,
Member.transaction, giftToken, giftSubscriptionsEnabled and ensure redeem
accepts/receives the transacting option and uses it for DB operations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e8571bb2-5431-485f-b49d-dac4266096ed

📥 Commits

Reviewing files that changed from the base of the PR and between d09caf3 and b1942c2.

📒 Files selected for processing (9)
  • ghost/core/core/server/services/gifts/gift-controller.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/core/server/services/members/members-api/repositories/member-repository.js
  • ghost/core/test/e2e-api/members/gift-subscriptions.test.js
  • ghost/core/test/unit/server/services/gifts/gift-controller.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
  • ghost/core/test/unit/server/services/members/members-api/members-api.test.js
  • ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js
✅ Files skipped from review due to trivial changes (2)
  • ghost/core/test/unit/server/services/members/members-api/members-api.test.js
  • ghost/core/test/e2e-api/members/gift-subscriptions.test.js
🚧 Files skipped from review as they are similar to previous changes (6)
  • ghost/core/core/server/services/gifts/gift-controller.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-controller.test.ts
  • ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js
  • ghost/core/core/server/services/members/members-api/repositories/member-repository.js

@sagzy sagzy force-pushed the gift-subscriptions/do-not-send-new-free-member-notification branch from b1942c2 to f13db08 Compare April 20, 2026 15:41
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ghost/core/test/unit/server/services/gifts/gift-service.test.ts (1)

1047-1074: Consider strengthening the newMember: true assertion to explicitly verify assertRedeemable(gift, null) is used.

The test currently stubs memberRepository.get to return a member with status 'gift', which also happens to satisfy checkRedeemable without newMember being set (only 'paid' is explicitly rejected). That means this test wouldn't fail if newMember were quietly ignored and the member's status were used instead.

To make the test actually prove the newMember branch in gift-service.ts (Lines 262-266), consider either:

  • Spying on service.assertRedeemable and asserting it's called with null as the second argument, or
  • Using a member status that would be rejected without the newMember override (though none of the current statuses behave that way, so a spy is probably cleaner).

Not blocking — the positive-path assertions still catch regressions in the overall flow.

♻️ Example: spy on assertRedeemable to pin the branch
         it('allows a newly created gift member to redeem when newMember is true', async function () {
             const gift = buildGift();

             giftRepository.getByToken.resolves(gift);
             memberRepository.get.resolves({
                 id: 'member_1',
                 get: sinon.stub().withArgs('status').returns('gift')
             });

             const service = createService();
+            const assertRedeemableSpy = sinon.spy(service, 'assertRedeemable');
             const redeemed = await service.redeem('gift-token', 'member_1', {newMember: true});

+            sinon.assert.calledOnceWithExactly(assertRedeemableSpy, gift, null);
             sinon.assert.calledOnce(giftRepository.transaction);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/unit/server/services/gifts/gift-service.test.ts` around lines
1047 - 1074, The test should assert that the newMember branch is exercised by
verifying assertRedeemable is called with null as the member argument; spy on
the service.assertRedeemable method (or sinon.spy on the service instance) prior
to calling service.redeem('gift-token', 'member_1', {newMember: true}) and
assert that assertRedeemable(gift, null) was invoked, so the test ensures the
code path in gift-service.ts (assertRedeemable used for newMember) is actually
taken rather than relying on the stubbed member status.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ghost/core/test/unit/server/services/gifts/gift-service.test.ts`:
- Around line 1047-1074: The test should assert that the newMember branch is
exercised by verifying assertRedeemable is called with null as the member
argument; spy on the service.assertRedeemable method (or sinon.spy on the
service instance) prior to calling service.redeem('gift-token', 'member_1',
{newMember: true}) and assert that assertRedeemable(gift, null) was invoked, so
the test ensures the code path in gift-service.ts (assertRedeemable used for
newMember) is actually taken rather than relying on the stubbed member status.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 13991906-d317-49bd-864b-be0331a99173

📥 Commits

Reviewing files that changed from the base of the PR and between b1942c2 and f13db08.

📒 Files selected for processing (9)
  • ghost/core/core/server/services/gifts/gift-controller.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/core/server/services/members/members-api/repositories/member-repository.js
  • ghost/core/test/e2e-api/members/gift-subscriptions.test.js
  • ghost/core/test/unit/server/services/gifts/gift-controller.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
  • ghost/core/test/unit/server/services/members/members-api/members-api.test.js
  • ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js
🚧 Files skipped from review as they are similar to previous changes (5)
  • ghost/core/test/unit/server/services/gifts/gift-controller.test.ts
  • ghost/core/core/server/services/gifts/gift-controller.ts
  • ghost/core/test/e2e-api/members/gift-subscriptions.test.js
  • ghost/core/test/unit/server/services/members/members-api/members-api.test.js
  • ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js

await buildRepo().create({
email: 'test@example.com',
name: 'Test Member',
status: 'gift'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also add products here to validate the line:

 if (memberData.products && memberData.products.length === 1) {

Copy link
Copy Markdown
Contributor Author

@sagzy sagzy Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added new unit test to cover that in 3e1a9c2

logging.error('Failed to notify staff of gift redemption', err);
if (options.transacting) {
// Only notify once the transaction has finished
options.transacting.executionPromise.then(notify, () => {});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we want to catch and log or something here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a warning log that said "Skipped staff notification on gift redemption (transaction rollback)" initially, but I'm not sure this is valuable, as this is sort of expected behaviour (transaction failed, don't continue)?

The error log from the caller are more valuable: transaction failed because of x

What do you think?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, yeh i guess there probably isn't much value to this. We also try/catch in the notify fn anyway so nothing should be thrown?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep exactly

}

const memberData = _.pick(data, ['email', 'name', 'note', 'subscribed', 'geolocation', 'created_at', 'products', 'newsletters', 'email_disabled']);
const memberData = _.pick(data, ['email', 'name', 'note', 'subscribed', 'geolocation', 'created_at', 'products', 'newsletters', 'email_disabled', 'status']);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

status will get validated at the model / db level right? So no issue if we pass an invalid value for status?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An invalid status will be rejected at the model level yes. But I'm thinking we should add an extra validation in the create() / update() method and reject invalid status values with a ValidationError, so that we can test this properly and return a more readable error

Copy link
Copy Markdown
Contributor Author

@sagzy sagzy Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 3e1a9c2

sagzy added 2 commits April 21, 2026 11:31
closes https://linear.app/ghost/issue/BER-3476

- when a new member redeemed a gift subscription, they were briefly created
  with status 'free' and only flipped to 'gift' by the subsequent redeem
  call, which triggered the "new free signup" staff notification - now fixed
- they would also receive the free member welcome email - now fixed
- wrapped member creation and gift redemption in a transaction to ensure atomicity
@sagzy sagzy force-pushed the gift-subscriptions/do-not-send-new-free-member-notification branch from daf3c02 to 3e1a9c2 Compare April 21, 2026 09:31
@sonarqubecloud
Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@sagzy sagzy enabled auto-merge (squash) April 21, 2026 09:35
@sagzy sagzy disabled auto-merge April 21, 2026 09:35
@sagzy sagzy enabled auto-merge (squash) April 21, 2026 09:37
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
ghost/core/test/unit/server/services/gifts/gift-service.test.ts (1)

1017-1045: Make the external-transaction test prove notification deferral.

Using Promise.resolve() lets the notification run immediately, so this only proves the repo calls use the external transaction. A deferred executionPromise would catch regressions that notify before commit/rollback is known.

🧪 Proposed test tightening
-            memberRepository.get.resolves({
-                id: 'member_1',
-                get: sinon.stub().withArgs('status').returns('free')
-            });
+            const memberGet = sinon.stub();
+            memberGet.withArgs('status').returns('free');
+            memberGet.withArgs('email').returns('member@example.com');
+            memberGet.withArgs('name').returns('Member Name');
+            memberRepository.get.resolves({
+                id: 'member_1',
+                get: memberGet
+            });
 
             const service = createService();
-            const externalTrx = {executionPromise: Promise.resolve()};
+            let resolveExecutionPromise!: (value?: unknown) => void;
+            const executionPromise = new Promise<unknown>((resolve) => {
+                resolveExecutionPromise = resolve;
+            });
+            const externalTrx = {executionPromise};
             const redeemed = await service.redeem('gift-token', 'member_1', {transacting: externalTrx});
 
             sinon.assert.notCalled(giftRepository.transaction);
@@
             });
             sinon.assert.calledOnceWithExactly(giftRepository.update, redeemed, {transacting: externalTrx});
+            sinon.assert.notCalled(staffServiceEmails.notifyGiftSubscriptionStarted);
+
+            resolveExecutionPromise();
+            await executionPromise;
+            await new Promise(resolve => setImmediate(resolve));
+
+            sinon.assert.calledOnce(staffServiceEmails.notifyGiftSubscriptionStarted);
             assert.equal(redeemed.status, 'redeemed');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/unit/server/services/gifts/gift-service.test.ts` around lines
1017 - 1045, Replace the immediate Promise.resolve() used for
externalTrx.executionPromise with a deferred promise so the test can assert
notifications are not delivered until the transaction's executionPromise
settles: create a deferred object (e.g., let resolveExecution; const deferred =
new Promise(res => { resolveExecution = res; }); set externalTrx =
{executionPromise: deferred}; call service.redeem('gift-token', ...) and assert
repo methods were called but any notification stubs/spies were not called yet,
then call resolveExecution() and await the redeem result (or await
externalTrx.executionPromise) and finally assert the notification was called and
redeemed.status is 'redeemed'; keep references to externalTrx, executionPromise
and service.redeem when updating the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@ghost/core/core/server/services/members/members-api/repositories/member-repository.js`:
- Around line 346-359: Change validation and defaulting to check for the
presence of the status key instead of truthiness: in member-repository.js
replace the truthy checks on memberData.status with presence checks (e.g.
Object.prototype.hasOwnProperty.call(memberData, 'status') or 'status' in
memberData) so that update() will validate and persist explicit values like ''
or 'gift' (if present, throw ValidationError when memberData.status is not in
MEMBER_STATUSES); and only apply the comped/free default when status is not
provided at all (i.e. when the status property is absent). Apply the same
presence-based logic to the analogous block referenced around 536-600.

---

Nitpick comments:
In `@ghost/core/test/unit/server/services/gifts/gift-service.test.ts`:
- Around line 1017-1045: Replace the immediate Promise.resolve() used for
externalTrx.executionPromise with a deferred promise so the test can assert
notifications are not delivered until the transaction's executionPromise
settles: create a deferred object (e.g., let resolveExecution; const deferred =
new Promise(res => { resolveExecution = res; }); set externalTrx =
{executionPromise: deferred}; call service.redeem('gift-token', ...) and assert
repo methods were called but any notification stubs/spies were not called yet,
then call resolveExecution() and await the redeem result (or await
externalTrx.executionPromise) and finally assert the notification was called and
redeemed.status is 'redeemed'; keep references to externalTrx, executionPromise
and service.redeem when updating the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4c2157e1-0469-4675-bfc6-c047f5549e5e

📥 Commits

Reviewing files that changed from the base of the PR and between f13db08 and 3e1a9c2.

📒 Files selected for processing (9)
  • ghost/core/core/server/services/gifts/gift-controller.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/core/server/services/members/members-api/repositories/member-repository.js
  • ghost/core/test/e2e-api/members/gift-subscriptions.test.js
  • ghost/core/test/unit/server/services/gifts/gift-controller.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
  • ghost/core/test/unit/server/services/members/members-api/members-api.test.js
  • ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js
🚧 Files skipped from review as they are similar to previous changes (4)
  • ghost/core/core/server/services/gifts/gift-controller.ts
  • ghost/core/test/e2e-api/members/gift-subscriptions.test.js
  • ghost/core/test/unit/server/services/members/members-api/members-api.test.js
  • ghost/core/core/server/services/members/members-api/members-api.js

Comment on lines +346 to 359
if (memberData.status && !MEMBER_STATUSES.includes(memberData.status)) {
throw new errors.ValidationError({
message: tpl(messages.invalidMemberStatus, {statuses: MEMBER_STATUSES.join(', ')}),
property: 'status'
});
}

if (memberData.products && memberData.products.length === 1) {
memberStatusData.status = 'comped';
if (!memberData.status) {
if (memberData.products && memberData.products.length === 1) {
memberData.status = 'comped';
} else {
memberData.status = 'free';
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Persist explicit status updates and validate by presence, not truthiness.

update() now validates data.status, but memberData still omits status, so repo.update({status: 'gift'}, ...) is silently ignored unless a product-change branch also sets memberStatusData.status. Also, status: '' bypasses validation in both create/update and is treated as omitted. Please include status in the update payload and validate when the field is present.

🐛 Proposed fix
+        const hasMemberStatus = Object.prototype.hasOwnProperty.call(memberData, 'status');
-        if (memberData.status && !MEMBER_STATUSES.includes(memberData.status)) {
+        if (hasMemberStatus && !MEMBER_STATUSES.includes(memberData.status)) {
             throw new errors.ValidationError({
                 message: tpl(messages.invalidMemberStatus, {statuses: MEMBER_STATUSES.join(', ')}),
                 property: 'status'
             });
         }
 
-        if (!memberData.status) {
+        if (!hasMemberStatus) {
             if (memberData.products && memberData.products.length === 1) {
                 memberData.status = 'comped';
             } else {
                 memberData.status = 'free';
             }
@@
             'expertise',
             'email_disabled',
-            'transient_id'
+            'transient_id',
+            'status'
         ]);
@@
-        if (data.status && !MEMBER_STATUSES.includes(data.status)) {
+        if (Object.prototype.hasOwnProperty.call(data, 'status') && !MEMBER_STATUSES.includes(data.status)) {
             throw new errors.ValidationError({
                 message: tpl(messages.invalidMemberStatus, {statuses: MEMBER_STATUSES.join(', ')}),
                 property: 'status'
             });
         }

Also applies to: 536-600

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/services/members/members-api/repositories/member-repository.js`
around lines 346 - 359, Change validation and defaulting to check for the
presence of the status key instead of truthiness: in member-repository.js
replace the truthy checks on memberData.status with presence checks (e.g.
Object.prototype.hasOwnProperty.call(memberData, 'status') or 'status' in
memberData) so that update() will validate and persist explicit values like ''
or 'gift' (if present, throw ValidationError when memberData.status is not in
MEMBER_STATUSES); and only apply the comped/free default when status is not
provided at all (i.e. when the status property is absent). Apply the same
presence-based logic to the analogous block referenced around 536-600.

@sagzy sagzy merged commit b2f88c2 into main Apr 21, 2026
43 checks passed
@sagzy sagzy deleted the gift-subscriptions/do-not-send-new-free-member-notification branch April 21, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants