Skip to content

Wired up gift redemption rendering#27209

Merged
sagzy merged 2 commits intomainfrom
gift-subscriptions/wire-up-redemption-screen
Apr 8, 2026
Merged

Wired up gift redemption rendering#27209
sagzy merged 2 commits intomainfrom
gift-subscriptions/wire-up-redemption-screen

Conversation

@sagzy
Copy link
Copy Markdown
Contributor

@sagzy sagzy commented Apr 7, 2026

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

Summary

This PR adds a gift redemption preflight endpoint for Portal:

GET /members/api/gifts/:token/redeem/

The endpoint is read-only and answers one question before any redemption write happens: can this token be redeemed right now by the current visitor?

It supports both anonymous visitors and logged-in members. If the token is redeemable, it returns the gift details Portal needs to render the redemption screen. If not, it returns a standard members API error response with a user-facing message.

The endpoint validates gift redeemability in this order:

  1. Gift subscriptions labs flag is enabled
  2. A gift exists for the token
  3. The gift has not already been redeemed
  4. The gift has not expired
  5. If a member is logged in, they do not already have active access
  6. If all checks pass, the gift is considered redeemable and the endpoint returns the gift details.

API contract

GET /members/api/gifts/:token/redeem/

Success response:

{
  "gifts": [
    {
      "token": "gift-token-123",
      "cadence": "year",
      "duration": 1,
      "currency": "usd",
      "amount": 5000,
      "expires_at": "2030-01-01T00:00:00.000Z",
      "tier": {
        "id": "tier_123",
        "name": "Bronze",
        "description": "Tier description",
        "benefits": [
          "Benefit 1",
          "Benefit 2"
        ]
      }
    }
  ]
}

Error response:

{
  "errors": [
    {
      "message": "This gift has expired.",
      "type": "BadRequestError"
    }
  ]
}

Expected error messages include:

  • "Gift not found."
  • "This gift has already been redeemed."
  • "This gift has expired."
  • "You already have an active subscription."
  • "Gift subscriptions are not enabled on this site."

Portal Flow

  • A visitor opens #/portal/gift/redeem/:token
  • Portal calls GET /members/api/gifts/:token/redeem/
  • The gift service validates the token and current member eligibility
  • If valid, the endpoint returns the gift payload in the standard members API envelope
  • Portal reads response and renders the redemption UI
  • If invalid, Portal surfaces the API error message through the existing notification flow

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 7, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a members API GET endpoint /api/gifts/:token/redeem and its handler returning { gifts: [gift] }. Introduces GiftBookshelfRepository.getByToken and GiftRepository.getByToken, extends GiftService with getRedeemableGiftByToken (feature-flag gating, redemption/expiry/member-state checks, tier lookup, normalized tier/benefits), and wires a labsService dependency. Portal client now fetches real redemption data from the new endpoint and maps the first array element into page state; the gift-redemption UI accepts tier benefits as strings or objects. Adds unit, integration, e2e, and portal tests and bumps portal package version.

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% 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 directly references the main objective: wiring up gift redemption rendering by adding a read-only preflight endpoint.
Description check ✅ Passed The PR description comprehensively details the gift redemption endpoint implementation, including validation order, API contract, response formats, and Portal integration flow.

✏️ 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/wire-up-redemption-screen

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.

@sagzy sagzy force-pushed the gift-subscriptions/wire-up-redemption-screen branch from c683148 to 27b5a83 Compare April 7, 2026 21:03
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c6831488fd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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: 2

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

67-83: The dependency injection pattern already uses lazy evaluation via object property getters.

Lines 23–30 in gift-service-wrapper.js pass memberRepository, staffServiceEmails, and tiersServiceAPI as getter properties, which are evaluated on-demand when accessed by GiftService, not when the service is instantiated. The async init() method ensures these services are only required when initialization runs, deferring the boot-time work.

However, labsService on line 32 is passed directly rather than as a getter, inconsistently breaking the lazy pattern. Consider wrapping it as a getter too for consistency.

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

In `@ghost/core/core/server/services/gifts/gift-service.ts` around lines 67 - 83,
The constructor currently assigns labsService directly to this.#labsService
breaking the lazy-getter pattern used for other deps; change the constructor to
accept labsService the same way (wrap as a getter) and assign a getter (e.g.,
this.#getLabsService = () => dependencies.labsService) and then update internal
uses to call the getter (use this.#getLabsService().isSet(...)) so labsService
is evaluated on-demand like `#getMemberRepository`, `#getStaffServiceEmails` and
`#getTiersServiceAPI`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/portal/test/api.test.js`:
- Around line 54-69: HumanReadableError.fromApiResponse currently only extracts
user-facing messages for 400, 429, and 500, causing 404 responses like
{errors:[{message:'Gift not found.'}]} to be lost; update
HumanReadableError.fromApiResponse to also handle status 404 by parsing JSON
(check Content-Type) and returning the API-provided errors[0].message as the
human-readable message (falling back to the existing generic message if errors
or message are missing) so calls like ghostApi.gift.fetchRedemptionData({token})
receive the preserved "Gift not found." text.

In `@ghost/core/core/server/services/gifts/gift-service.ts`:
- Around line 163-167: The preflight expiry check currently only inspects
gift.expiredAt, so tokens with gift.expiresAt in the past but not yet swept are
treated as redeemable; update the check in gift-service.ts to also consider
gift.expiresAt by throwing when gift.expiredAt !== null OR (gift.expiresAt !==
null && gift.expiresAt <= now), using the same error/message
(tpl(messages.giftExpired)); also add a regression test asserting that a gift
with expiresAt set to a past timestamp and expiredAt === null is rejected by the
same preflight/redeem check.

---

Nitpick comments:
In `@ghost/core/core/server/services/gifts/gift-service.ts`:
- Around line 67-83: The constructor currently assigns labsService directly to
this.#labsService breaking the lazy-getter pattern used for other deps; change
the constructor to accept labsService the same way (wrap as a getter) and assign
a getter (e.g., this.#getLabsService = () => dependencies.labsService) and then
update internal uses to call the getter (use this.#getLabsService().isSet(...))
so labsService is evaluated on-demand like `#getMemberRepository`,
`#getStaffServiceEmails` and `#getTiersServiceAPI`.
🪄 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: de4eb784-c509-42e2-b9c1-70b5e85a7e2e

📥 Commits

Reviewing files that changed from the base of the PR and between 874626c and 27b5a83.

📒 Files selected for processing (14)
  • apps/portal/src/app.js
  • apps/portal/src/components/pages/gift-redemption-page.js
  • apps/portal/src/utils/api.js
  • apps/portal/test/api.test.js
  • apps/portal/test/portal-links.test.js
  • ghost/core/core/server/api/endpoints/gifts-members.js
  • ghost/core/core/server/api/endpoints/index.js
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-service-wrapper.js
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/web/members/app.js
  • ghost/core/test/e2e-api/members/gifts.test.js
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts

@sagzy sagzy force-pushed the gift-subscriptions/wire-up-redemption-screen branch from 27b5a83 to cbe4044 Compare April 8, 2026 07:22
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 (3)
apps/portal/src/utils/errors.js (2)

22-38: Consider extracting shared JSON error parsing logic.

The JSON parsing and error extraction logic in lines 29-37 is nearly identical to lines 12-20 (400/429 handling). A helper function could reduce duplication and ensure consistent behavior across status codes.

♻️ Optional: Extract shared logic
+    async _extractJsonErrorMessage(res) {
+        try {
+            const json = await res.json();
+            if (json.errors && Array.isArray(json.errors) && json.errors.length > 0 && json.errors[0].message) {
+                return new HumanReadableError(json.errors[0].message);
+            }
+        } catch (e) {
+            // Failed to decode: ignore
+        }
+        return undefined;
+    }
+
     static async fromApiResponse(res) {
         // Bad request + Too many requests
         if (res.status === 400 || res.status === 429) {
-            try {
-                const json = await res.json();
-                if (json.errors && Array.isArray(json.errors) && json.errors.length > 0 && json.errors[0].message) {
-                    return new HumanReadableError(json.errors[0].message);
-                }
-            } catch (e) {
-                // Failed to decode: ignore
-                return undefined;
-            }
+            return this._extractJsonErrorMessage(res);
         }
         if (res.status === 404) {
             const contentType = (res.headers.get('content-type') || '').toLowerCase();

             if (!contentType.includes('application/json')) {
                 return undefined;
             }

-            try {
-                const json = await res.json();
-                if (json.errors && Array.isArray(json.errors) && json.errors.length > 0 && json.errors[0].message) {
-                    return new HumanReadableError(json.errors[0].message);
-                }
-            } catch (e) {
-                // Failed to decode: ignore
-                return undefined;
-            }
+            return this._extractJsonErrorMessage(res);
         }

Note: The content-type check you added for 404 is a good defensive measure. You might consider applying it to the 400/429 branch as well for consistency.

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

In `@apps/portal/src/utils/errors.js` around lines 22 - 38, Extract the duplicated
JSON error parsing into a single helper (e.g.,
extractHumanReadableErrorFromResponse or parseJsonError) that: checks
content-type contains "application/json", attempts res.json() in a try/catch,
and returns a HumanReadableError when json.errors[0].message exists or undefined
otherwise; then replace the inline parsing in the 400/429 branch and the 404
branch to call that helper so both branches share identical behavior and the
content-type check is applied consistently.

64-86: Consider adding gift-related error messages for i18n.

The new gift redemption flow returns API error messages like "Gift not found.", "This gift has already been redeemed.", "This gift has expired.", "You already have an active subscription.", and "Gift subscriptions are not enabled on this site." These messages will not be translated unless added to specialMessages.

If internationalization is required for the gift feature, consider adding:

t('Gift not found.');
t('This gift has already been redeemed.');
t('This gift has expired.');
t('You already have an active subscription.');
t('Gift subscriptions are not enabled on this site.');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/portal/src/utils/errors.js` around lines 64 - 86, The i18n
specialMessages list in setupSpecialMessages currently omits gift-related error
strings, so add the new gift messages by invoking the local t function (the
closure that pushes into specialMessages) with each gift error: "Gift not
found.", "This gift has already been redeemed.", "This gift has expired.", "You
already have an active subscription.", and "Gift subscriptions are not enabled
on this site." so they are picked up by the i18n-parser and included in
specialMessages.
ghost/core/test/unit/server/services/gifts/gift-service.test.ts (1)

322-338: Consider using relative dates for time-dependent expiry tests.

This test uses a hard-coded past date (2026-01-15) that works now but creates maintenance burden. Using relative dates makes tests self-documenting and future-proof.

♻️ Suggested approach
         it('throws BadRequestError when the gift expiry timestamp is in the past but not yet swept', async function () {
+            const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
             giftRepository.getByToken.resolves(buildGift({
-                expiresAt: new Date('2026-01-15T00:00:00.000Z'),
+                expiresAt: pastDate,
                 expiredAt: null
             }));
🤖 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
322 - 338, The test uses a hard-coded date (new
Date('2026-01-15T00:00:00.000Z')) which will become brittle; update the spec in
the test that calls giftRepository.getByToken and buildGift to use relative
dates instead (e.g., compute expiresAt as a Date based on Date.now() +/- offset
so it's consistently in the past or future for the scenario), keep expiredAt as
null, and keep the same assertions around service.getRedeemableGiftByToken;
update the instantiation via createService() and the mocked getByToken call
(symbols: giftRepository.getByToken, buildGift, createService,
getRedeemableGiftByToken) so the test uses a dynamically computed expiry
timestamp rather than a fixed calendar date.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/portal/src/utils/errors.js`:
- Around line 22-38: Extract the duplicated JSON error parsing into a single
helper (e.g., extractHumanReadableErrorFromResponse or parseJsonError) that:
checks content-type contains "application/json", attempts res.json() in a
try/catch, and returns a HumanReadableError when json.errors[0].message exists
or undefined otherwise; then replace the inline parsing in the 400/429 branch
and the 404 branch to call that helper so both branches share identical behavior
and the content-type check is applied consistently.
- Around line 64-86: The i18n specialMessages list in setupSpecialMessages
currently omits gift-related error strings, so add the new gift messages by
invoking the local t function (the closure that pushes into specialMessages)
with each gift error: "Gift not found.", "This gift has already been redeemed.",
"This gift has expired.", "You already have an active subscription.", and "Gift
subscriptions are not enabled on this site." so they are picked up by the
i18n-parser and included in specialMessages.

In `@ghost/core/test/unit/server/services/gifts/gift-service.test.ts`:
- Around line 322-338: The test uses a hard-coded date (new
Date('2026-01-15T00:00:00.000Z')) which will become brittle; update the spec in
the test that calls giftRepository.getByToken and buildGift to use relative
dates instead (e.g., compute expiresAt as a Date based on Date.now() +/- offset
so it's consistently in the past or future for the scenario), keep expiredAt as
null, and keep the same assertions around service.getRedeemableGiftByToken;
update the instantiation via createService() and the mocked getByToken call
(symbols: giftRepository.getByToken, buildGift, createService,
getRedeemableGiftByToken) so the test uses a dynamically computed expiry
timestamp rather than a fixed calendar date.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 75c7c50e-081b-4248-a44a-63a6d6a79319

📥 Commits

Reviewing files that changed from the base of the PR and between 27b5a83 and cbe4044.

📒 Files selected for processing (17)
  • apps/portal/package.json
  • apps/portal/src/app.js
  • apps/portal/src/components/pages/gift-redemption-page.js
  • apps/portal/src/utils/api.js
  • apps/portal/src/utils/errors.js
  • apps/portal/test/api.test.js
  • apps/portal/test/errors.test.js
  • apps/portal/test/portal-links.test.js
  • ghost/core/core/server/api/endpoints/gifts-members.js
  • ghost/core/core/server/api/endpoints/index.js
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-service-wrapper.js
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/web/members/app.js
  • ghost/core/test/e2e-api/members/gifts.test.js
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
✅ Files skipped from review due to trivial changes (5)
  • apps/portal/test/errors.test.js
  • apps/portal/package.json
  • apps/portal/src/app.js
  • apps/portal/test/api.test.js
  • ghost/core/core/server/api/endpoints/index.js
🚧 Files skipped from review as they are similar to previous changes (9)
  • ghost/core/core/server/web/members/app.js
  • ghost/core/core/server/services/gifts/gift-service-wrapper.js
  • ghost/core/core/server/api/endpoints/gifts-members.js
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • apps/portal/src/components/pages/gift-redemption-page.js
  • apps/portal/src/utils/api.js
  • apps/portal/test/portal-links.test.js
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/test/e2e-api/members/gifts.test.js

@sagzy sagzy force-pushed the gift-subscriptions/wire-up-redemption-screen branch from cbe4044 to 7640067 Compare April 8, 2026 07:57
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/gifts/gift-service.ts (1)

26-33: Consider simplifying the toJSON return type.

The toJSON() method's return type duplicates the RedeemableTier structure. This could be simplified to reduce maintenance burden.

♻️ Suggested simplification
 type RedeemableTierRecord = RedeemableTier & {
-    toJSON?: () => {
-        id: string;
-        name: string;
-        description: string | null;
-        benefits: string[] | null;
-    };
+    toJSON?: () => RedeemableTier;
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift-service.ts` around lines 26 - 33,
The explicit toJSON return type in RedeemableTierRecord duplicates
RedeemableTier; change the declaration of RedeemableTierRecord (and its toJSON)
to reuse the existing RedeemableTier type instead of repeating fields—e.g., make
toJSON return RedeemableTier or a narrowed type like Pick<RedeemableTier,
'id'|'name'|'description'|'benefits'> (or Partial/ Omit as appropriate) so the
shape is maintained from the original RedeemableTier definition and reduces
duplicated maintenance.
🤖 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/gifts/gift-service.ts`:
- Around line 26-33: The explicit toJSON return type in RedeemableTierRecord
duplicates RedeemableTier; change the declaration of RedeemableTierRecord (and
its toJSON) to reuse the existing RedeemableTier type instead of repeating
fields—e.g., make toJSON return RedeemableTier or a narrowed type like
Pick<RedeemableTier, 'id'|'name'|'description'|'benefits'> (or Partial/ Omit as
appropriate) so the shape is maintained from the original RedeemableTier
definition and reduces duplicated maintenance.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f82ff5d4-c7cc-4d4c-992e-2ad315ea1ff7

📥 Commits

Reviewing files that changed from the base of the PR and between cbe4044 and 7640067.

📒 Files selected for processing (17)
  • apps/portal/package.json
  • apps/portal/src/app.js
  • apps/portal/src/components/pages/gift-redemption-page.js
  • apps/portal/src/utils/api.js
  • apps/portal/src/utils/errors.js
  • apps/portal/test/api.test.js
  • apps/portal/test/errors.test.js
  • apps/portal/test/portal-links.test.js
  • ghost/core/core/server/api/endpoints/gifts-members.js
  • ghost/core/core/server/api/endpoints/index.js
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-service-wrapper.js
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/web/members/app.js
  • ghost/core/test/e2e-api/members/gifts.test.js
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
✅ Files skipped from review due to trivial changes (2)
  • apps/portal/package.json
  • apps/portal/src/components/pages/gift-redemption-page.js
🚧 Files skipped from review as they are similar to previous changes (10)
  • ghost/core/core/server/api/endpoints/index.js
  • apps/portal/test/portal-links.test.js
  • ghost/core/core/server/api/endpoints/gifts-members.js
  • ghost/core/core/server/web/members/app.js
  • apps/portal/src/app.js
  • apps/portal/src/utils/api.js
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • apps/portal/test/api.test.js
  • ghost/core/test/e2e-api/members/gifts.test.js

@sagzy sagzy force-pushed the gift-subscriptions/wire-up-redemption-screen branch from 7640067 to 69e12da Compare April 8, 2026 08:58
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 (2)
ghost/core/core/server/services/gifts/gift-service.ts (1)

97-98: Minor: Missing semicolon for consistency.

Line 97 is missing a semicolon after StaffServiceEmails, unlike other properties in the type definition.

🔧 Suggested fix
         giftEmailService: GiftEmailService;
-        staffServiceEmails: StaffServiceEmails
+        staffServiceEmails: StaffServiceEmails;
         labsService: LabsService;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift-service.ts` around lines 97 - 98,
The type/property declaration for staffServiceEmails in gift-service.ts is
missing a trailing semicolon for consistency; update the declaration of
staffServiceEmails: StaffServiceEmails to include a semicolon so it matches
other properties like labsService: LabsService; and maintains consistent
punctuation in the type/interface definition.
ghost/core/test/e2e-api/members/gifts.test.js (1)

183-195: Consider adding edge case: expires_at in past with expired_at still null.

The expired gift test sets expired_at, but doesn't cover the scenario where expires_at is in the past but expired_at is still null (gift not yet swept by background job). This edge case is handled in the service code (line 201 of gift-service.ts), but adding an e2e test here would provide additional regression protection.

💡 Suggested additional test case
it('returns 400 when the gift expires_at is in the past but not yet swept', async function () {
    const pastDate = new Date('2020-01-01T00:00:00.000Z');
    const gift = await createGift({
        expires_at: pastDate,
        expired_at: null  // Not yet swept by background job
    });

    const {body} = await membersAgent
        .get(`/api/gifts/${gift.get('token')}/redeem/`)
        .expectStatus(400);

    assert.equal(body.errors[0].message, expiredMessage);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/e2e-api/members/gifts.test.js` around lines 183 - 195, Add an
E2E test in gifts.test.js that covers the edge case where expires_at is in the
past but expired_at is still null: use createGift({expires_at: pastDate,
expired_at: null}), call
membersAgent.get(`/api/gifts/${gift.get('token')}/redeem/`) and
expectStatus(400), then assert body.errors[0].message === expiredMessage; place
this alongside the existing "returns 400 when the gift has expired" test so
gift-service logic that checks expires_at (not just expired_at) is exercised.
🤖 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/gifts/gift-service.ts`:
- Around line 221-235: The local Tier type used in gift-service.ts is missing
the id property, causing a type mismatch with the Tier instances returned by
tiersService.api.read(); update the local type definition named Tier (the type
declared around the top of gift-service.ts) to include id: string; so
tier.toJSON() / tier.id accesses match the type, then re-run typechecks and
adjust any nullable/optional annotations if needed.

---

Nitpick comments:
In `@ghost/core/core/server/services/gifts/gift-service.ts`:
- Around line 97-98: The type/property declaration for staffServiceEmails in
gift-service.ts is missing a trailing semicolon for consistency; update the
declaration of staffServiceEmails: StaffServiceEmails to include a semicolon so
it matches other properties like labsService: LabsService; and maintains
consistent punctuation in the type/interface definition.

In `@ghost/core/test/e2e-api/members/gifts.test.js`:
- Around line 183-195: Add an E2E test in gifts.test.js that covers the edge
case where expires_at is in the past but expired_at is still null: use
createGift({expires_at: pastDate, expired_at: null}), call
membersAgent.get(`/api/gifts/${gift.get('token')}/redeem/`) and
expectStatus(400), then assert body.errors[0].message === expiredMessage; place
this alongside the existing "returns 400 when the gift has expired" test so
gift-service logic that checks expires_at (not just expired_at) is exercised.
🪄 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: dd65612b-f15c-40f4-92de-16f8789ec1fa

📥 Commits

Reviewing files that changed from the base of the PR and between 7640067 and 69e12da.

📒 Files selected for processing (18)
  • apps/portal/package.json
  • apps/portal/src/app.js
  • apps/portal/src/components/pages/gift-redemption-page.js
  • apps/portal/src/utils/api.js
  • apps/portal/src/utils/errors.js
  • apps/portal/test/api.test.js
  • apps/portal/test/errors.test.js
  • apps/portal/test/portal-links.test.js
  • ghost/core/core/boot.js
  • ghost/core/core/server/api/endpoints/gifts-members.js
  • ghost/core/core/server/api/endpoints/index.js
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-service-wrapper.js
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/web/members/app.js
  • ghost/core/test/e2e-api/members/gifts.test.js
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
✅ Files skipped from review due to trivial changes (5)
  • apps/portal/package.json
  • apps/portal/src/components/pages/gift-redemption-page.js
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • apps/portal/src/app.js
  • apps/portal/test/api.test.js
🚧 Files skipped from review as they are similar to previous changes (8)
  • ghost/core/core/server/api/endpoints/index.js
  • ghost/core/core/server/api/endpoints/gifts-members.js
  • ghost/core/core/server/services/gifts/gift-service-wrapper.js
  • apps/portal/src/utils/errors.js
  • apps/portal/test/portal-links.test.js
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • apps/portal/test/errors.test.js
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts

@sagzy sagzy force-pushed the gift-subscriptions/wire-up-redemption-screen branch from 69e12da to bbf1acf Compare April 8, 2026 09:20
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: 4

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

203-207: Consider using a distinct error message when the tier is missing.

When the tier lookup fails (line 203-207), the error message says "Gift not found." but the actual issue is that the gift's tier doesn't exist. This could be confusing for debugging. Consider using a more specific internal message while keeping the user-facing message generic, or logging the tier lookup failure for observability.

💡 Optional: Add logging for tier lookup failure
         if (!tier) {
+            logging.warn(`Tier ${gift.tierId} not found for gift ${gift.token}`);
             throw new errors.NotFoundError({
                 message: tpl(messages.giftNotFound)
             });
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift-service.ts` around lines 203 -
207, The current NotFoundError thrown when the tier lookup fails uses
messages.giftNotFound which is misleading; update the check that throws (the if
(!tier) block that currently throws errors.NotFoundError with
tpl(messages.giftNotFound)) to use a distinct internal message (e.g.
messages.giftTierNotFound or a new string) while keeping the user-facing message
generic, or log the specific tier lookup failure before throwing the existing
giftNotFound error; reference the same throw site, the tier variable,
errors.NotFoundError, and tpl(messages.giftNotFound) when making the change.
🤖 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/api/endpoints/index.js`:
- Around line 292-294: The giftsMembers export is returned raw and should be
wrapped with the API pipeline like other member endpoints; update the getter for
giftsMembers to return apiFramework.pipeline(require('./gifts-members'), {role:
'members'}) (matching how commentsMembers/feedbackMembers are wrapped) so the
endpoint gets framework validation, serialization, and error handling; then
verify the callers in the members app (where api.feedbackMembers is passed to
http()) now receive a pipeline-wrapped handler for consistent behavior.

In `@ghost/core/test/unit/server/services/gifts/gift-service.test.ts`:
- Around line 49-56: Remove the unresolved merge markers and keep the updated
stub block that includes getByToken; replace the conflicted lines with a clean
initialization using create: sinon.stub(), existsByCheckoutSessionId:
sinon.stub().resolves(false), and getByToken: sinon.stub().resolves(null) so the
stubs for create, existsByCheckoutSessionId, and getByToken are present and the
conflict markers (<<<<<<<, =======, >>>>>>>) are removed.
- Around line 8-16: Remove the unresolved Git conflict markers and restore a
valid declaration for giftRepository by keeping the new shape that includes all
three stubs: create, existsByCheckoutSessionId, and getByToken (i.e., replace
the entire conflicted block containing <<<<<<< HEAD, =======, and >>>>>>> with a
single declaration for giftRepository that defines those three sinon.SinonStub
properties so tests compile).
- Around line 87-124: Resolve the merge conflict by keeping the newer
implementation: return a GiftService constructed with the labsService included
(keep the GiftService instantiation that passes labsService) and retain the
buildGift helper function that constructs a new Gift with defaults (refer to
GiftService, labsService, buildGift and the Gift constructor) so the test file
contains the updated constructor call and the buildGift helper instead of the
old conflicting block.

---

Nitpick comments:
In `@ghost/core/core/server/services/gifts/gift-service.ts`:
- Around line 203-207: The current NotFoundError thrown when the tier lookup
fails uses messages.giftNotFound which is misleading; update the check that
throws (the if (!tier) block that currently throws errors.NotFoundError with
tpl(messages.giftNotFound)) to use a distinct internal message (e.g.
messages.giftTierNotFound or a new string) while keeping the user-facing message
generic, or log the specific tier lookup failure before throwing the existing
giftNotFound error; reference the same throw site, the tier variable,
errors.NotFoundError, and tpl(messages.giftNotFound) when making the change.
🪄 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: 96ec2fe9-8f1f-44f7-955f-6078b0b8fc6a

📥 Commits

Reviewing files that changed from the base of the PR and between 69e12da and bbf1acf.

📒 Files selected for processing (19)
  • apps/portal/package.json
  • apps/portal/src/app.js
  • apps/portal/src/components/pages/gift-redemption-page.js
  • apps/portal/src/utils/api.js
  • apps/portal/src/utils/errors.js
  • apps/portal/test/api.test.js
  • apps/portal/test/errors.test.js
  • apps/portal/test/portal-links.test.js
  • ghost/core/core/boot.js
  • ghost/core/core/server/api/endpoints/gifts-members.js
  • ghost/core/core/server/api/endpoints/index.js
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-repository.ts
  • ghost/core/core/server/services/gifts/gift-service-wrapper.js
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/web/members/app.js
  • ghost/core/test/e2e-api/members/gifts.test.js
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
✅ Files skipped from review due to trivial changes (8)
  • ghost/core/core/boot.js
  • apps/portal/package.json
  • ghost/core/core/server/services/gifts/gift-repository.ts
  • apps/portal/src/app.js
  • apps/portal/test/errors.test.js
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • apps/portal/src/components/pages/gift-redemption-page.js
  • ghost/core/test/e2e-api/members/gifts.test.js
🚧 Files skipped from review as they are similar to previous changes (6)
  • ghost/core/core/server/services/gifts/gift-service-wrapper.js
  • apps/portal/test/portal-links.test.js
  • ghost/core/core/server/api/endpoints/gifts-members.js
  • ghost/core/core/server/web/members/app.js
  • apps/portal/src/utils/api.js
  • apps/portal/test/api.test.js

@sagzy sagzy force-pushed the gift-subscriptions/wire-up-redemption-screen branch 4 times, most recently from 3c7be97 to 3ae8731 Compare April 8, 2026 09:51
return true;
}

async getRedeemableGiftByToken({token, currentMember}: {token: string; currentMember?: {status: string} | null}) {
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.

We should also add a check for refunded_at

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.

What do you think to moving entity level validation into the Gift entity?

Something like:

class Gift {
    isReedemable(reedemDate, memberStatus) {
         if (this.expiredAt !== null) {
             return false;
         }

         if (reedemDate > this.expiresAt) {
             return false;
         }

         if (memberStatus !== 'free') {
            return false;
         }
    }
}

This keeps the business rules with the entity, but the service still needs to perform other checks (e.g gift existence, valid tier)

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.

Could return status from the method also:

export const REDEEM_FAILURE_REASON_EXPIRED = 'expired'
export const REDEEM_FAILURE_REASON_REDEEMED = 'redeemed'

class Gift {
    isReedemable(reedemDate, memberStatus) {
         if (this.expiredAt !== null) {
             return REDEEM_FAILURE_REASON_EXPIRED;
         }

         if (reedemDate > this.expiresAt) {
             return REDEEM_FAILURE_REASON_REDEEMED;
         }
    }
}
switch (gift.isRedeemable(Date.now(), member.status)) {
    case REDEEM_FAILURE_REASON_EXPIRED:
        ...

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 great input, thanks! I've kept the member check out of the gift entity, but have otherwise pushed the "is this gift redeemable" business logic to the gift entity in 3ef33cd

Curious what you think!

assert.equal(body.errors[0].message, alreadyRedeemedMessage);
});

it('returns 400 when the gift has expired', async function () {
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 add a test case that also covers the expiresAt path?

Copy link
Copy Markdown
Contributor Author

@sagzy sagzy Apr 8, 2026

Choose a reason for hiding this comment

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

I've actually removed the expires_at (with a s) path, and kept the expired_at (with a d) check only - we also rely on these to be not-null for the already redeemed / consumed / refunded checks, so think we can simplify and do the same for expired

const {GiftEmailService} = require('./gift-email-service');
const membersService = require('../members');
const tiersService = require('../tiers/service');
const tiersService = require('../tiers');
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.

huh, did we not need to reference the tiers service like this?

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.

tiers/index.js has

module.exports = require('./service');

So const tiersService = require('../tiers'); or const tiersService = require('../tiers/service'); are equivalent

stripe_payment_intent_id: string;
consumes_at: Date | null;
expires_at: Date | null;
status: 'purchased' | 'redeemed' | 'consumed' | 'expired' | 'refunded';
Copy link
Copy Markdown
Member

@mike182uk mike182uk Apr 8, 2026

Choose a reason for hiding this comment

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

Hm, wonder if we should move this (and cadence) to Gift and export from there so we don't have these values in multiple places

export type GiftStatus = 'purchased' | ...
export type GiftCadence = 'month' | ...

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.

Good shout, fixed in 3ef33cd

@sagzy sagzy force-pushed the gift-subscriptions/wire-up-redemption-screen branch from 3ae8731 to 3ef33cd Compare April 8, 2026 12:54
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Apr 8, 2026

@sagzy sagzy merged commit 4990833 into main Apr 8, 2026
39 checks passed
@sagzy sagzy deleted the gift-subscriptions/wire-up-redemption-screen branch April 8, 2026 13:45
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