Skip to content

Added a job to remind members before their gift subscription ends#27436

Open
mike182uk wants to merge 1 commit intomainfrom
BER-3525-add-job-to-send-reminder
Open

Added a job to remind members before their gift subscription ends#27436
mike182uk wants to merge 1 commit intomainfrom
BER-3525-add-job-to-send-reminder

Conversation

@mike182uk
Copy link
Copy Markdown
Member

ref https://linear.app/ghost/issue/BER-3525

  • sends a one-time reminder email to the gift redeemer 7 days before their paid access ends (consumes_at)
  • introduces processReminders on GiftService + daily send-gift-reminders worker, gated on the giftSubscriptions labs flag
  • skips gifts that consume within the next 3 days (GIFT_REMINDER_FLOOR_DAYS) - a reminder with too little time to act on is worse than no reminder
  • records consumes_soon_reminder_sent_at inside the locked transaction before sending the email: any duplicate reminder path (e.g. the scheduler integration shipping in a follow-up PR) serialises on the row lock and no-ops, guaranteeing at-most-once delivery at the cost of a possible missed reminder on SMTP failure
  • tier lookup happens before the transaction so a missing tier is a recoverable failure - the next run will pick the gift up again after the admin restores the tier
  • per-gift errors are caught and counted as failedCount so a single transient failure doesn't abort the whole batch
  • email-disabled and missing-email redeemers are skipped but still marked as reminded to prevent retry loops

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 16, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5c9f1b69-5665-4ad3-9eec-689e2dd54579

📥 Commits

Reviewing files that changed from the base of the PR and between bf545de and adffd9a.

📒 Files selected for processing (19)
  • ghost/core/core/server/services/gifts/constants.ts
  • ghost/core/core/server/services/gifts/email-templates/gift-reminder.hbs
  • ghost/core/core/server/services/gifts/email-templates/gift-reminder.ts
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-email-renderer.ts
  • ghost/core/core/server/services/gifts/gift-email-service.ts
  • ghost/core/core/server/services/gifts/gift-repository.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/gifts/gift.ts
  • ghost/core/core/server/services/members/jobs/index.js
  • ghost/core/core/server/services/members/jobs/send-gift-reminders.js
  • ghost/core/core/server/services/members/service.js
  • ghost/core/test/integration/services/members/send-gift-reminders.test.js
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-controller.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-email-service.test.js
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
  • ghost/core/test/unit/server/services/gifts/gift.test.ts
  • ghost/core/test/unit/server/services/gifts/utils.ts
✅ Files skipped from review due to trivial changes (9)
  • ghost/core/test/unit/server/services/gifts/gift-controller.test.ts
  • ghost/core/core/server/services/gifts/constants.ts
  • ghost/core/test/unit/server/services/gifts/utils.ts
  • ghost/core/core/server/services/gifts/gift-repository.ts
  • ghost/core/core/server/services/gifts/email-templates/gift-reminder.hbs
  • ghost/core/core/server/services/members/jobs/send-gift-reminders.js
  • ghost/core/core/server/services/gifts/email-templates/gift-reminder.ts
  • ghost/core/test/unit/server/services/gifts/gift-email-service.test.js
  • ghost/core/core/server/services/gifts/gift-email-renderer.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • ghost/core/core/server/services/members/service.js
  • ghost/core/core/server/services/gifts/gift-email-service.ts
  • ghost/core/test/integration/services/members/send-gift-reminders.test.js
  • ghost/core/core/server/services/gifts/gift-service.ts

Walkthrough

Adds gift subscription reminder functionality: introduces two timing constants, an HTML and plaintext reminder email template and renderer, and a reminder send flow in the email service. Extends the Gift domain with a nullable consumesSoonReminderSentAt timestamp and a remind() method. Repository layer gains findPendingReminder(...) and persists the new timestamp. GiftService acquires processReminders() to query pending gifts, mark them reminded, and send emails; a scheduled job send-gift-reminders.js is added to trigger processing. Tests and scheduler wiring are updated accordingly.

Possibly related PRs

Suggested reviewers

  • sagzy
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding a job to remind members before their gift subscription ends, which is the core functionality introduced across the changeset.
Description check ✅ Passed The description is directly related to the changeset, detailing the reminder email feature, processing logic, delivery guarantees, error handling, and edge cases covered by the changes.

✏️ 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 BER-3525-add-job-to-send-reminder

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

@mike182uk mike182uk force-pushed the BER-3525-add-job-to-send-reminder branch from a44c3c1 to bf545de Compare April 16, 2026 17:17
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/integration/services/members/send-gift-reminders.test.js (1)

171-188: Add the missing-email variant next to this skip-path test.

email_disabled and email === null are separate reminder guards, but only one is exercised end-to-end here. A small sibling test would protect against trying to send to an empty address or failing after the reminder stamp is written.

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

In `@ghost/core/test/integration/services/members/send-gift-reminders.test.js`
around lines 171 - 188, Add a sibling test mirroring the existing "marks the
gift as reminded but does not email when the redeemer has email_disabled" case
but instead set the redeemer's email to null (e.g., await
models.Member.edit({email: null}, {id: redeemerMember.id});), create the same
inWindow gift with createRedeemedGift({consumesAt: inWindow}), call
giftService.service.processReminders(), and assert the same results:
remindedCount 0, skippedCount 1, failedCount 0,
emailMockReceiver.assertSentEmailCount(0), and that
models.Gift.findOne(...).get('consumes_soon_reminder_sent_at') is set to ensure
the reminder stamp is written even when email is missing.
🤖 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/jobs/send-gift-reminders.js`:
- Around line 9-25: The cancel path currently only posts a 'cancelled' message
to parentPort and doesn't stop the running job, so late cancels still let the
async worker finish and post 'done'; fix by wiring a cancellation/abort signal
into the worker: create an AbortController in the worker scope, pass its signal
into processReminders() (and any downstream async functions) so they can
early-return when signal.aborted, and in cancel() call controller.abort() and
ensure the worker stops (avoid posting 'done' after abort and exit/cleanup as
before); update the parentPort.once('message') handler to call cancel() which
now aborts the controller and prevents further processing.

---

Nitpick comments:
In `@ghost/core/test/integration/services/members/send-gift-reminders.test.js`:
- Around line 171-188: Add a sibling test mirroring the existing "marks the gift
as reminded but does not email when the redeemer has email_disabled" case but
instead set the redeemer's email to null (e.g., await models.Member.edit({email:
null}, {id: redeemerMember.id});), create the same inWindow gift with
createRedeemedGift({consumesAt: inWindow}), call
giftService.service.processReminders(), and assert the same results:
remindedCount 0, skippedCount 1, failedCount 0,
emailMockReceiver.assertSentEmailCount(0), and that
models.Gift.findOne(...).get('consumes_soon_reminder_sent_at') is set to ensure
the reminder stamp is written even when email is missing.
🪄 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: e18775f2-d332-47eb-a87c-0d2e61d143ce

📥 Commits

Reviewing files that changed from the base of the PR and between 2788016 and bf545de.

📒 Files selected for processing (19)
  • ghost/core/core/server/services/gifts/constants.ts
  • ghost/core/core/server/services/gifts/email-templates/gift-reminder.hbs
  • ghost/core/core/server/services/gifts/email-templates/gift-reminder.ts
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-email-renderer.ts
  • ghost/core/core/server/services/gifts/gift-email-service.ts
  • ghost/core/core/server/services/gifts/gift-repository.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/gifts/gift.ts
  • ghost/core/core/server/services/members/jobs/index.js
  • ghost/core/core/server/services/members/jobs/send-gift-reminders.js
  • ghost/core/core/server/services/members/service.js
  • ghost/core/test/integration/services/members/send-gift-reminders.test.js
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-controller.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-email-service.test.js
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
  • ghost/core/test/unit/server/services/gifts/gift.test.ts
  • ghost/core/test/unit/server/services/gifts/utils.ts

Comment on lines +9 to +25
function cancel() {
if (parentPort) {
parentPort.postMessage('Gift reminder job cancelled before completion');
parentPort.postMessage('cancelled');
} else {
setTimeout(() => {
process.exit(0);
}, 1000);
}
}

if (parentPort) {
parentPort.once('message', (message) => {
if (message === 'cancel') {
return cancel();
}
});
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

Cancellation only acknowledges the message; it does not stop the worker.

With parentPort, cancel() just posts 'cancelled'. The async IIFE keeps running, so a late cancel can still initialize services, call processReminders(), send emails, and then post 'done'. That defeats the shutdown path and can report conflicting terminal states.

Suggested direction
+let cancelled = false;
+
 function cancel() {
+    cancelled = true;
     if (parentPort) {
         parentPort.postMessage('Gift reminder job cancelled before completion');
         parentPort.postMessage('cancelled');
-    } else {
-        setTimeout(() => {
-            process.exit(0);
-        }, 1000);
+        return;
     }
+
+    setTimeout(() => {
+        process.exit(0);
+    }, 1000);
 }
 
 if (parentPort) {
     parentPort.once('message', (message) => {
         if (message === 'cancel') {
@@
 (async () => {
     try {
+        if (cancelled) {
+            return;
+        }
+
         const startDate = new Date();
         debug('Starting gift reminder send');
 
         const giftService = require('../../gifts');
         await giftService.init();
+
+        if (cancelled) {
+            return;
+        }
+
         const {remindedCount, skippedCount, failedCount} = await giftService.service.processReminders();

If shutdown is expected to interrupt an in-flight batch too, this needs to be plumbed through processReminders() with an abort signal rather than only acknowledging the parent message.

Also applies to: 28-58

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

In `@ghost/core/core/server/services/members/jobs/send-gift-reminders.js` around
lines 9 - 25, The cancel path currently only posts a 'cancelled' message to
parentPort and doesn't stop the running job, so late cancels still let the async
worker finish and post 'done'; fix by wiring a cancellation/abort signal into
the worker: create an AbortController in the worker scope, pass its signal into
processReminders() (and any downstream async functions) so they can early-return
when signal.aborted, and in cancel() call controller.abort() and ensure the
worker stops (avoid posting 'done' after abort and exit/cleanup as before);
update the parentPort.once('message') handler to call cancel() which now aborts
the controller and prevents further processing.

 ref https://linear.app/ghost/issue/BER-3525

 - sends a one-time reminder email to the gift redeemer 7 days before their paid access ends (`consumes_at`)
 - introduces `processReminders` on `GiftService` + daily `send-gift-reminders` worker, gated on the `giftSubscriptions` labs flag
 - skips gifts that consume within the next 3 days (`GIFT_REMINDER_FLOOR_DAYS`) - a reminder with too little time to act on is worse than no reminder
 - records `consumes_soon_reminder_sent_at` inside the locked transaction before sending the email: any duplicate reminder path (e.g. the scheduler integration shipping in a follow-up PR) serialises on the row lock and no-ops, guaranteeing at-most-once delivery at the cost of a possible missed reminder on SMTP failure
 - tier lookup happens before the transaction so a missing tier is a recoverable failure - the next run will pick the gift up again after the admin restores the tier
 - per-gift errors are caught and counted as `failedCount` so a single transient failure doesn't abort the whole batch
 - email-disabled and missing-email redeemers are skipped but still marked as reminded to prevent retry loops
@mike182uk mike182uk force-pushed the BER-3525-add-job-to-send-reminder branch from bf545de to adffd9a Compare April 16, 2026 17:44
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
1 Security Hotspot

See analysis details on SonarQube Cloud

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.

1 participant