Skip to content

Reworked member filters on a shared filter core#26828

Merged
kevinansfield merged 35 commits intomainfrom
ber-3414-rework-filters-for-members-only
Mar 19, 2026
Merged

Reworked member filters on a shared filter core#26828
kevinansfield merged 35 commits intomainfrom
ber-3414-rework-filters-for-members-only

Conversation

@jonatansberg
Copy link
Copy Markdown
Member

@jonatansberg jonatansberg commented Mar 16, 2026

Summary

  • added a shared filter core for field definitions, field resolution, AST helpers, codecs, and NQL query compilation
  • moved the members list filter state, query translation, hydration, and bulk/export/search flows onto that shared core while keeping the existing membersForward rollout model unchanged
  • removed the legacy members filter config path and intentionally left the comments moderation runtime on its current implementation for follow-up work

Why this change

The existing members filter stack had too many overlapping sources of truth across field configuration, query serialization, and runtime state. This branch consolidates that logic into a smaller shared core and uses it to drive the React members experience behind the existing membersForward rollout.

The comments moderation rewrite is intentionally not included here. That work now lives in the follow-up stacked PR linked below so this PR can stand on its own as a mergeable members-focused change.

Follow-up PR

What changed

  • introduced a shared filter layer under apps/posts/src/views/filters/ for:
    • field definitions
    • field resolution and parse aliases
    • AST inspection helpers
    • normalization helpers
    • shared codecs for simple NQL shapes
    • simple-node query dispatch and serialization
  • rebuilt the members filter implementation around that shared layer:
    • declarative member field definitions
    • explicit member compound parsing for the backend-required legacy shapes
    • separate member search handling alongside filter
    • simplified retention offer handling at the offer field boundary
    • shared operator label generation and resource-search overrides in hydration
  • removed the old use-members-filter-config path and updated the member actions/query-param plumbing to use the new state model
  • added the local @tryghost/nql-lang typing shim required by the new posts-side imports

Test plan

  • yarn --cwd apps/posts tsc --noEmit
  • yarn --cwd apps/posts lint
  • yarn --cwd apps/posts vitest run src/views/filters/filter-query-core.test.ts src/views/filters/filter-ast.test.ts src/views/filters/filter-codecs.test.ts src/views/filters/filter-operator-options.test.ts src/views/filters/filter-normalization.test.ts src/views/filters/filter-types.test.ts src/views/filters/resolve-field.test.ts src/views/members/member-query-params.test.ts src/views/members/member-fields.test.ts src/views/members/use-member-filter-fields.test.ts src/views/members/member-filter-query.test.ts src/views/members/hooks/use-members-filter-state.test.tsx

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 16, 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

Centralized member filtering and search across the posts app: adds AST parsing, codecs (scalar/text/set/number), filter normalization, filter-query core, member-specific parsing/serialization, field definitions, query-param builders, resource-search filter, and buildMembersUrl. Introduces member filter UI field builders and hooks, refactors members filter state to be timezone-aware and to separate NQL vs search, updates members list/actions to propagate search and hasFilterOrSearch, and adds many unit tests. In admin-x-framework, bulk member mutations accepted optional filter/search and use a shared buildBulkMemberSearchParams helper.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.29% 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 'Reworked member filters on a shared filter core' accurately captures the main change: refactoring member filters to use a shared filter core system.
Description check ✅ Passed The PR description is comprehensive and directly related to the changeset, providing a clear summary, rationale, scope of changes, and test plan for the member filter refactoring.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ber-3414-rework-filters-for-members-only
📝 Coding Plan
  • Generate coding plan for human review comments

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.

@github-actions
Copy link
Copy Markdown
Contributor

E2E Tests Failed

To view the Playwright test report locally, run:

REPORT_DIR=$(mktemp -d) && gh run download 23148563662 -n playwright-report -D "$REPORT_DIR" && npx playwright show-report "$REPORT_DIR"

Copy link
Copy Markdown
Contributor

@rob-ghost rob-ghost left a comment

Choose a reason for hiding this comment

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

My only feedback in the form of a PR you can accept / reject, entirely up to you! Approved regardless! 👍🏻

@jonatansberg jonatansberg force-pushed the ber-3414-rework-filters-for-members-only branch from 02ae53b to 75a96b6 Compare March 17, 2026 09:55
@jonatansberg jonatansberg marked this pull request as ready for review March 17, 2026 13:40
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: e3e5e1ff4b

ℹ️ 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: 8

🧹 Nitpick comments (2)
apps/posts/src/views/members/member-query-params.ts (1)

22-34: Consider documenting the hardcoded 'UTC' timezone.

The resolveField call uses a hardcoded 'UTC' timezone rather than the site's configured timezone. This appears intentional since getMemberActiveColumns only extracts metadata (activeColumn) and doesn't perform timezone-sensitive operations. A brief comment would clarify this is deliberate.

💡 Suggested comment
 export function getMemberActiveColumns(filters: FilterPredicate[]): ActiveColumn[] {
     const columns = new Map<string, ActiveColumn>();

     for (const filter of filters) {
+        // Timezone is not used for activeColumn metadata extraction
         const activeColumn = resolveField(memberFields, filter.field, 'UTC')?.definition.metadata?.activeColumn;

         if (activeColumn) {
             columns.set(activeColumn.key, activeColumn);
         }
     }

     return Array.from(columns.values());
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/members/member-query-params.ts` around lines 22 - 34,
Add a brief inline comment in getMemberActiveColumns explaining the intentional
use of the hardcoded 'UTC' timezone when calling resolveField(memberFields,
filter.field, 'UTC') because this function only reads metadata (activeColumn)
and does not perform timezone-sensitive operations; update the comment to
mention that using the site/configured timezone is unnecessary here to avoid
confusion for future maintainers.
apps/posts/src/views/members/components/members-filters.tsx (1)

23-23: Consider simplifying the EMPTY_OFFERS type definition.

The conditional type extraction works but is verbose. Since buildOfferOptions accepts Offer[], an explicit type would be clearer:

💡 Suggested simplification
-const EMPTY_OFFERS: typeof buildOfferOptions extends (offers: infer T) => unknown ? T : never = [];
+const EMPTY_OFFERS: Parameters<typeof buildOfferOptions>[0] = [];

Alternatively, import the Offer type directly if available.

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

In `@apps/posts/src/views/members/components/members-filters.tsx` at line 23, The
EMPTY_OFFERS declaration uses a verbose conditional type; replace it with a
simple explicit type annotation (e.g., Offer[] or the concrete array type
returned/expected by buildOfferOptions) by importing the Offer type if necessary
and changing the declaration of EMPTY_OFFERS to use that explicit type instead
of the conditional typeof buildOfferOptions inference; update the symbol
EMPTY_OFFERS in members-filters.tsx and ensure buildOfferOptions usage remains
compatible.
🤖 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/posts/src/views/filters/filter-codecs.ts`:
- Around line 54-64: serializeScalarValue currently only quotes strings with
whitespace, causing inputs like "foo+bar", "a,b", "x]" or "O'Brien" to be
emitted raw and break round-tripping for scalarCodec()/setCodec(). Update
serializeScalarValue to call escapeNqlString not only when config?.quoteStrings
or whitespace is present, but also when the string contains NQL metacharacters
(e.g. characters such as + , - , , , ] , [ , ' , " , :) so that any scalar or
set element that would parse differently is escaped; ensure this behavior
applies to values passed through setCodec() since it relies on
serializeScalarValue. Use the existing escapeNqlString helper and keep honoring
config?.quoteStrings.

In `@apps/posts/src/views/members/hooks/use-members-filter-state.ts`:
- Line 8: The SetFiltersAction functional overload on setFilters in
useMembersFilterState is unsafe because its updater is evaluated against the
render-time filters snapshot and drops sequential updates; remove the function
overload and only accept a concrete Filter[] (or, alternatively, resolve
updaters against the current URL-backed state before applying), update any
callsites that pass a function to instead read the current filters from the URL
state (or compute the new array) and pass an explicit Filter[] to setFilters,
and adjust related code in the same file (including the logic referenced around
lines 53-58) to stop relying on the callback form so updates are applied
deterministically.
- Around line 42-47: The code currently calls
parseMemberFilter(searchParams.get('filter'), timezone) before useBrowseSettings
resolves (timezone derived from getSiteTimezone(settingsData?.settings)), which
canonicalizes date predicates to UTC bounds too early; change the useMemo so it
only canonicalizes dates once the site timezone is stable (e.g., check
settingsData or settingsData.settings truthy) — either: (A) delay calling
parseMemberFilter with the timezone until settingsData is available (include
settingsData in the memo deps and return an un-canonicalized/raw parse or
undefined until then), or (B) call parseMemberFilter without the timezone until
settingsData is ready to avoid getDayBoundsInUtc normalization; also ensure any
callers that mutate the URL (setFilters, setSearch, clearFilters) guard against
running while settingsData is unresolved. Reference: useBrowseSettings,
getSiteTimezone, parseMemberFilter, setFilters, setSearch, clearFilters.

In `@apps/posts/src/views/members/member-fields.ts`:
- Around line 16-18: NUMBER_OPERATORS currently only lists
['is','is-greater','is-less'] so inclusive numeric predicates like 'is-or-less'
and 'is-or-greater' are missing and break representation of legacy filters
(e.g., email_count, email_opened_count, email_open_rate). Update the
NUMBER_OPERATORS constant in member-fields.ts to include the inclusive operators
('is-or-less' and 'is-or-greater') so it aligns with the shared numeric
pipeline; verify any switch/handler that consumes NUMBER_OPERATORS (e.g.,
predicate parsing/serialization paths) will accept these new tokens for fields
like email_count, email_opened_count, and email_open_rate.

In `@apps/posts/src/views/members/member-filter-query.ts`:
- Around line 177-183: The code currently only special-cases compound.operator
=== '$and' and falls through to dispatchSimpleNodes for other compounds (e.g.,
'$or'), which can silently drop clauses; modify the logic in the parse path that
uses getCompoundChildren(node) so that if compound exists and compound.operator
!== '$and' you either (a) preserve the original node by returning [node] so it
survives hydration, or (b) explicitly reject unsupported operators by throwing
an informative error (e.g., referencing compound.operator) instead of letting
dispatchSimpleNodes drop it; update the branch around getCompoundChildren,
parseMemberNode, and dispatchSimpleNodes accordingly to implement one of these
behaviors.

In `@apps/posts/src/views/members/member-route.ts`:
- Around line 9-15: The explicit filter option is being set then potentially
overwritten by query.filter during the generic query iteration; change the loop
in member-route.ts that iterates Object.entries(query ?? {}) to skip the
'filter' key (i.e., if (key === 'filter') continue) so the earlier
searchParams.set('filter', filter) always wins, and add a regression test that
supplies both filter and query.filter to ensure the canonical filter is
preserved in the generated members URL.

In `@apps/posts/src/views/members/members.tsx`:
- Around line 126-128: The empty-state message shown when hasFilterOrSearch is
true uses "No members match the current filter", which is incorrect when only
search is active; update the EmptyIndicator usage (the title prop passed to
EmptyIndicator in the members view) to a neutral string such as "No members
match the current criteria" so it accurately covers both filter and search-only
states (replace the existing title text wherever hasFilterOrSearch is used).

In `@apps/posts/src/views/PostAnalytics/Growth/growth.tsx`:
- Line 26: The navigateToMembers helper currently only forwards filter to
buildMembersUrl and so drops the postAnalytics context when navigating with
crossApp: true; update navigateToMembers (and the call-site that builds the URL)
to include the postAnalytics value (e.g., accept or capture a postAnalytics
param or pull it from scope) and pass it into buildMembersUrl alongside filter
so the generated URL retains postAnalytics before calling navigate({crossApp:
true}). Ensure you reference and update the navigateToMembers function and the
buildMembersUrl invocation to include the postAnalytics identifier.

---

Nitpick comments:
In `@apps/posts/src/views/members/components/members-filters.tsx`:
- Line 23: The EMPTY_OFFERS declaration uses a verbose conditional type; replace
it with a simple explicit type annotation (e.g., Offer[] or the concrete array
type returned/expected by buildOfferOptions) by importing the Offer type if
necessary and changing the declaration of EMPTY_OFFERS to use that explicit type
instead of the conditional typeof buildOfferOptions inference; update the symbol
EMPTY_OFFERS in members-filters.tsx and ensure buildOfferOptions usage remains
compatible.

In `@apps/posts/src/views/members/member-query-params.ts`:
- Around line 22-34: Add a brief inline comment in getMemberActiveColumns
explaining the intentional use of the hardcoded 'UTC' timezone when calling
resolveField(memberFields, filter.field, 'UTC') because this function only reads
metadata (activeColumn) and does not perform timezone-sensitive operations;
update the comment to mention that using the site/configured timezone is
unnecessary here to avoid confusion for future maintainers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 387b69a6-a45d-4a9d-9f01-29b75b3673cc

📥 Commits

Reviewing files that changed from the base of the PR and between 28d542f and e3e5e1f.

📒 Files selected for processing (40)
  • apps/admin-x-framework/src/api/members.ts
  • apps/posts/src/nql-lang.d.ts
  • apps/posts/src/views/PostAnalytics/Growth/growth.tsx
  • apps/posts/src/views/PostAnalytics/Newsletter/newsletter.tsx
  • apps/posts/src/views/filters/filter-ast.test.ts
  • apps/posts/src/views/filters/filter-ast.ts
  • apps/posts/src/views/filters/filter-codecs.test.ts
  • apps/posts/src/views/filters/filter-codecs.ts
  • apps/posts/src/views/filters/filter-normalization.test.ts
  • apps/posts/src/views/filters/filter-normalization.ts
  • apps/posts/src/views/filters/filter-query-core.test.ts
  • apps/posts/src/views/filters/filter-query-core.ts
  • apps/posts/src/views/filters/filter-types.test.ts
  • apps/posts/src/views/filters/filter-types.ts
  • apps/posts/src/views/filters/resolve-field.test.ts
  • apps/posts/src/views/filters/resolve-field.ts
  • apps/posts/src/views/members/components/bulk-action-modals/remove-label-modal.tsx
  • apps/posts/src/views/members/components/bulk-action-modals/unsubscribe-modal.tsx
  • apps/posts/src/views/members/components/members-actions.tsx
  • apps/posts/src/views/members/components/members-filters.tsx
  • apps/posts/src/views/members/components/members-header.tsx
  • apps/posts/src/views/members/components/members-list-item.tsx
  • apps/posts/src/views/members/components/members-list.tsx
  • apps/posts/src/views/members/hooks/use-members-filter-config.tsx
  • apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx
  • apps/posts/src/views/members/hooks/use-members-filter-state.ts
  • apps/posts/src/views/members/hooks/use-resource-search.ts
  • apps/posts/src/views/members/member-fields.test.ts
  • apps/posts/src/views/members/member-fields.ts
  • apps/posts/src/views/members/member-filter-query.test.ts
  • apps/posts/src/views/members/member-filter-query.ts
  • apps/posts/src/views/members/member-query-params.test.ts
  • apps/posts/src/views/members/member-query-params.ts
  • apps/posts/src/views/members/member-route.test.ts
  • apps/posts/src/views/members/member-route.ts
  • apps/posts/src/views/members/members.tsx
  • apps/posts/src/views/members/use-member-filter-fields.test.ts
  • apps/posts/src/views/members/use-member-filter-fields.ts
  • apps/posts/vitest.config.ts
  • ghost/core/test/unit/server/lib/lexical.test.js
💤 Files with no reviewable changes (1)
  • apps/posts/src/views/members/hooks/use-members-filter-config.tsx

@jonatansberg jonatansberg force-pushed the ber-3414-rework-filters-for-members-only branch from e3e5e1f to 48ebfcf Compare March 17, 2026 16:06
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: 48ebfcfe86

ℹ️ 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: 3

Caution

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

⚠️ Outside diff range comments (1)
apps/posts/src/views/members/hooks/use-resource-search.ts (1)

15-20: ⚠️ Potential issue | 🟡 Minor

Backslash characters are not escaped, which can break NQL parsing.

The function escapes single quotes but not backslashes. If a user searches for a string containing a trailing backslash (e.g., test\), the resulting filter title:~'test\' has \' interpreted as an escaped quote, leaving the string unterminated and causing a parse error.

🔧 Proposed fix to escape backslashes
 function buildFilter(baseFilter: string, search: string): string {
     if (!search) {
         return baseFilter;
     }
-    return `${baseFilter}+title:~'${search.replace(/'/g, '\\\'')}'`;
+    const escaped = search.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+    return `${baseFilter}+title:~'${escaped}'`;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/members/hooks/use-resource-search.ts` around lines 15 -
20, The buildFilter function currently only escapes single quotes but not
backslashes, which breaks NQL when a search contains backslashes; update
buildFilter to first escape backslashes (replace each "\" with "\\") and then
escape single quotes (replace "'" with "\'") before interpolating into
`${baseFilter}+title:~'...'`, ensuring both backslashes and quotes in the search
string are properly escaped; reference the buildFilter function to locate and
modify the string replacement logic.
♻️ Duplicate comments (6)
apps/posts/src/views/members/hooks/use-members-filter-state.ts (2)

8-8: ⚠️ Potential issue | 🟡 Minor

The functional setFilters overload is still non-atomic.

action(filters) runs against the memoized filters from the current render, not the current URL state. Two setFilters(prev => ...) calls in the same turn can therefore overwrite each other. Either drop the callback form or resolve it from fresh searchParams before serializing.

#!/bin/bash
set -euo pipefail

echo "Function-form setFilters callsites:"
rg -nP --type=ts --type=tsx '\bsetFilters\s*\(\s*[A-Za-z_$][\w$]*\s*=>' apps/posts/src

echo
echo "useMembersFilterState consumers:"
rg -nP --type=ts --type=tsx '\buseMembersFilterState\s*\(' apps/posts/src -C2

Also applies to: 18-18, 53-58

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

In `@apps/posts/src/views/members/hooks/use-members-filter-state.ts` at line 8,
The SetFiltersAction supports a functional form but setFilters applies the
function to the memoized filters from the render (filters) instead of resolving
from the current URL state, making updates non-atomic; update
useMembersFilterState so the functional overload is removed or (preferred)
implemented atomically by deriving the latest filters from current searchParams
inside setFilters: when setFilters is called with a function, first read and
parse fresh searchParams (the same way useMembersFilterState does to produce
filters), pass that fresh filters into the provided callback, then serialize and
write the result back to searchParams; adjust SetFiltersAction/type definitions
and the setFilters implementation to reference searchParams parsing/serializing
helpers used elsewhere so two functional setFilters calls in the same tick no
longer clobber each other (affects SetFiltersAction, setFilters, filters, and
useMembersFilterState).

42-68: ⚠️ Potential issue | 🔴 Critical

Don't canonicalize date filters until the site timezone is ready.

Before settings load, getSiteTimezone(settingsData?.settings ?? []) supplies a fallback zone. Any URL date filter is then parsed and reserialized against that fallback, so setFilters, setSearch, or clearFilters can rewrite the deep link with different UTC day bounds before the real site timezone arrives. Please gate parse/serialize on resolved settings, or avoid serializing while the timezone is still provisional.

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

In `@apps/posts/src/views/members/hooks/use-members-filter-state.ts` around lines
42 - 68, The code currently parses and reserializes date filters using a
provisional timezone before settings load; gate parsing/serializing on the
resolved settings by checking settingsData (from useBrowseSettings) is defined.
Specifically, only call getSiteTimezone and parseMemberFilter in the
filters/search useMemo when settingsData is available (e.g., if settingsData ===
undefined return undefined or ''), and in setFilters, setSearch and clearFilters
bail out (no-op) or queue until settingsData is loaded instead of calling
toSearchParams when settingsData is undefined; update references to timezone,
parseMemberFilter, toSearchParams, setFilters, setSearch, and clearFilters
accordingly so no URL rewrite occurs using the fallback timezone.
apps/posts/src/views/members/member-fields.ts (1)

15-16: ⚠️ Potential issue | 🟠 Major

Add the inclusive numeric operators to the schema.

email_count, email_opened_count, and email_open_rate all use NUMBER_OPERATORS, but that list still omits is-or-less and is-or-greater. Hydrating <= or >= member filters then produces predicates the UI cannot represent or round-trip.

Proposed fix
-const NUMBER_OPERATORS = ['is', 'is-greater', 'is-less'] as const;
+const NUMBER_OPERATORS = ['is', 'is-greater', 'is-or-greater', 'is-less', 'is-or-less'] as const;

Also applies to: 371-407

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

In `@apps/posts/src/views/members/member-fields.ts` around lines 15 - 16,
NUMBER_OPERATORS currently omits inclusive operators causing filters for
email_count, email_opened_count, and email_open_rate to be non-round-trip;
update the NUMBER_OPERATORS constant (and any duplicate definitions of it) to
include 'is-or-less' and 'is-or-greater' so numeric comparisons support <= and
>=, ensuring any place referencing NUMBER_OPERATORS (e.g., member-fields'
NUMBER_OPERATORS constant used by
email_count/email_opened_count/email_open_rate) is updated consistently.
apps/posts/src/views/members/member-filter-query.ts (1)

177-183: ⚠️ Potential issue | 🟠 Major

Don't pass unmatched compounds to dispatchSimpleNodes().

When none of the member-specific matchers handle a compound node, this fallback forwards the whole node to dispatchSimpleNodes(). In apps/posts/src/views/filters/filter-query-core.ts, that helper returns [] for keys starting with $, so an unsupported $or/compound clause disappears during hydration and the next serialize broadens the query. Preserve or explicitly reject unsupported compounds here instead of silently dropping them.

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

In `@apps/posts/src/views/members/member-filter-query.ts` around lines 177 - 183,
The current branch forwards unmatched compound nodes to dispatchSimpleNodes()
which causes unsupported compound operators (like $or) to be dropped; update the
logic in the block that calls getCompoundChildren(node) so that if a compound
exists but compound.operator !== '$and' you do not call dispatchSimpleNodes:
either return the original node (e.g., return [node]) to preserve it through
hydration, or explicitly throw/return a clear error for unsupported compound
operators; modify the code around getCompoundChildren, parseMemberNode and
dispatchSimpleNodes to implement one of these two behaviors (prefer explicit
rejection with a descriptive error mentioning the compound.operator if you want
to fail-fast).
apps/posts/src/views/members/members.tsx (1)

126-128: ⚠️ Potential issue | 🟡 Minor

Use neutral empty-state copy for search-only states.

Line 128 still says “current filter”, but hasFilterOrSearch is also true when only search is active. A neutral title avoids misleading copy in that case.

✏️ Suggested tweak
-                                <EmptyIndicator title="No members match the current filter">
+                                <EmptyIndicator title="No members match the current criteria">
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/members/members.tsx` around lines 126 - 128, The
empty-state title shown when hasFilterOrSearch is true uses "No members match
the current filter", which is misleading when only search is active; update the
EmptyIndicator title passed in that conditional (the JSX using hasFilterOrSearch
and EmptyIndicator) to a neutral string such as "No members found" or "No
members match your search or filters" so it accurately covers both filter and
search-only states.
apps/posts/src/views/filters/filter-codecs.ts (1)

54-64: ⚠️ Potential issue | 🟠 Major

Escape non-safe scalar values before serializing NQL.

serializeScalarValue() still only quotes whitespace. Values like foo+bar, a,b, -trial, or O'Brien are emitted raw and round-trip into a different filter, which breaks both scalarCodec() and setCodec().

🛠️ Suggested fix
+const SAFE_UNQUOTED_NQL_VALUE = /^[A-Za-z0-9_./]+(?:-[A-Za-z0-9_./]+)*$/;
+
 function serializeScalarValue(value: unknown, config?: CodecConfig): string {
     if (typeof value === 'string') {
-        if (config?.quoteStrings || /\s/.test(value)) {
+        if (config?.quoteStrings || !SAFE_UNQUOTED_NQL_VALUE.test(value)) {
             return escapeNqlString(value);
         }
 
         return value;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/filters/filter-codecs.ts` around lines 54 - 64,
serializeScalarValue currently only quotes strings containing whitespace,
causing unsafe tokens like "foo+bar", "a,b", "-trial", or "O'Brien" to be
emitted raw and break round-trips in scalarCodec() and setCodec(); update
serializeScalarValue to call escapeNqlString whenever config?.quoteStrings is
true OR the string contains any unsafe characters (e.g. whitespace, plus, comma,
single-quote, or a leading hyphen) by replacing the /\s/ check with a regex that
matches those characters or a leading hyphen (for example /[\s,+']|^-/) so all
such values are escaped before returning.
🧹 Nitpick comments (1)
apps/posts/src/views/members/member-route.test.ts (1)

11-18: Add collision/omission regression cases for query normalization.

Nice coverage for preserving extra params. Please also add tests for query.filter collision precedence and null/undefined query value omission to lock down expected behavior.

💡 Suggested test additions
 describe('buildMembersUrl', () => {
@@
     it('preserves additional query params alongside the canonical filter', () => {
         expect(buildMembersUrl({
             filter: 'emails.post_id:post_123',
             query: {
                 postAnalytics: 'post_123'
             }
         })).toBe('/members?filter=emails.post_id%3Apost_123&postAnalytics=post_123');
     });
+
+    it('prefers canonical filter over query.filter', () => {
+        expect(buildMembersUrl({
+            filter: 'status:paid',
+            query: {
+                filter: 'status:free'
+            }
+        })).toBe('/members?filter=status%3Apaid');
+    });
+
+    it('omits null/undefined query values', () => {
+        expect(buildMembersUrl({
+            filter: 'status:paid',
+            query: {
+                postAnalytics: undefined,
+                campaign: null,
+                source: 'growth'
+            }
+        })).toBe('/members?filter=status%3Apaid&source=growth');
+    });
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/members/member-route.test.ts` around lines 11 - 18, Add
two regression tests to member-route.test.ts around buildMembersUrl: one
asserting that when callers pass a query object containing filter (e.g., query:
{filter: 'x', ...}) the explicit top-level filter parameter (filter: 'y') wins
or is used according to the normalization rule (create a test named like
"query.filter collision prefers canonical filter" that verifies the expected
precedence), and another asserting that query keys with null or undefined values
are omitted from the resulting URL (create a test named like "omits
null/undefined query values") by passing query entries set to null/undefined and
expecting they do not appear in the built URL. Ensure both tests use
buildMembersUrl and assert exact string equality of the generated URL to lock
down behavior.
🤖 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/admin-x-framework/src/api/members.ts`:
- Around line 199-219: The helper buildBulkMemberSearchParams currently allows
all to be combined with filter or search which can create conflicting bulk
scopes; update buildBulkMemberSearchParams to treat all as mutually exclusive by
throwing an Error if all is true and either filter or search is provided, and
only set params.all when all is true alone; keep existing checks that require at
least one of all/filter/search and continue to populate params.filter and
params.search only when all is not set.

In `@apps/posts/src/views/members/use-member-filter-fields.ts`:
- Around line 402-410: The email_opened_count filter is being added whenever
emailAnalyticsEnabled is true even if open tracking is off; update the block in
use-member-filter-fields.ts so that createFieldConfig('email_opened_count') is
only pushed when emailTrackOpens is true (the same guard used for
createFieldConfig('email_open_rate')), i.e., add email_opened_count to
emailFields conditionally alongside email_open_rate using the emailTrackOpens
flag so open-based filters are hidden when opens are not tracked.

In `@apps/posts/src/views/PostAnalytics/Newsletter/newsletter.tsx`:
- Around line 80-85: Remove the unused postAnalytics query param from the
members navigation: update the navigateToMembers function so it calls
buildMembersUrl without adding the query.postAnalytics field (only provide the
filter and omit postAnalytics), i.e., remove the postAnalytics key from the
object passed to buildMembersUrl used by navigate/navigateToMembers; this keeps
navigation consistent with the members page state handled by
use-members-filter-state.ts which only reads filter and search.

---

Outside diff comments:
In `@apps/posts/src/views/members/hooks/use-resource-search.ts`:
- Around line 15-20: The buildFilter function currently only escapes single
quotes but not backslashes, which breaks NQL when a search contains backslashes;
update buildFilter to first escape backslashes (replace each "\" with "\\") and
then escape single quotes (replace "'" with "\'") before interpolating into
`${baseFilter}+title:~'...'`, ensuring both backslashes and quotes in the search
string are properly escaped; reference the buildFilter function to locate and
modify the string replacement logic.

---

Duplicate comments:
In `@apps/posts/src/views/filters/filter-codecs.ts`:
- Around line 54-64: serializeScalarValue currently only quotes strings
containing whitespace, causing unsafe tokens like "foo+bar", "a,b", "-trial", or
"O'Brien" to be emitted raw and break round-trips in scalarCodec() and
setCodec(); update serializeScalarValue to call escapeNqlString whenever
config?.quoteStrings is true OR the string contains any unsafe characters (e.g.
whitespace, plus, comma, single-quote, or a leading hyphen) by replacing the
/\s/ check with a regex that matches those characters or a leading hyphen (for
example /[\s,+']|^-/) so all such values are escaped before returning.

In `@apps/posts/src/views/members/hooks/use-members-filter-state.ts`:
- Line 8: The SetFiltersAction supports a functional form but setFilters applies
the function to the memoized filters from the render (filters) instead of
resolving from the current URL state, making updates non-atomic; update
useMembersFilterState so the functional overload is removed or (preferred)
implemented atomically by deriving the latest filters from current searchParams
inside setFilters: when setFilters is called with a function, first read and
parse fresh searchParams (the same way useMembersFilterState does to produce
filters), pass that fresh filters into the provided callback, then serialize and
write the result back to searchParams; adjust SetFiltersAction/type definitions
and the setFilters implementation to reference searchParams parsing/serializing
helpers used elsewhere so two functional setFilters calls in the same tick no
longer clobber each other (affects SetFiltersAction, setFilters, filters, and
useMembersFilterState).
- Around line 42-68: The code currently parses and reserializes date filters
using a provisional timezone before settings load; gate parsing/serializing on
the resolved settings by checking settingsData (from useBrowseSettings) is
defined. Specifically, only call getSiteTimezone and parseMemberFilter in the
filters/search useMemo when settingsData is available (e.g., if settingsData ===
undefined return undefined or ''), and in setFilters, setSearch and clearFilters
bail out (no-op) or queue until settingsData is loaded instead of calling
toSearchParams when settingsData is undefined; update references to timezone,
parseMemberFilter, toSearchParams, setFilters, setSearch, and clearFilters
accordingly so no URL rewrite occurs using the fallback timezone.

In `@apps/posts/src/views/members/member-fields.ts`:
- Around line 15-16: NUMBER_OPERATORS currently omits inclusive operators
causing filters for email_count, email_opened_count, and email_open_rate to be
non-round-trip; update the NUMBER_OPERATORS constant (and any duplicate
definitions of it) to include 'is-or-less' and 'is-or-greater' so numeric
comparisons support <= and >=, ensuring any place referencing NUMBER_OPERATORS
(e.g., member-fields' NUMBER_OPERATORS constant used by
email_count/email_opened_count/email_open_rate) is updated consistently.

In `@apps/posts/src/views/members/member-filter-query.ts`:
- Around line 177-183: The current branch forwards unmatched compound nodes to
dispatchSimpleNodes() which causes unsupported compound operators (like $or) to
be dropped; update the logic in the block that calls getCompoundChildren(node)
so that if a compound exists but compound.operator !== '$and' you do not call
dispatchSimpleNodes: either return the original node (e.g., return [node]) to
preserve it through hydration, or explicitly throw/return a clear error for
unsupported compound operators; modify the code around getCompoundChildren,
parseMemberNode and dispatchSimpleNodes to implement one of these two behaviors
(prefer explicit rejection with a descriptive error mentioning the
compound.operator if you want to fail-fast).

In `@apps/posts/src/views/members/members.tsx`:
- Around line 126-128: The empty-state title shown when hasFilterOrSearch is
true uses "No members match the current filter", which is misleading when only
search is active; update the EmptyIndicator title passed in that conditional
(the JSX using hasFilterOrSearch and EmptyIndicator) to a neutral string such as
"No members found" or "No members match your search or filters" so it accurately
covers both filter and search-only states.

---

Nitpick comments:
In `@apps/posts/src/views/members/member-route.test.ts`:
- Around line 11-18: Add two regression tests to member-route.test.ts around
buildMembersUrl: one asserting that when callers pass a query object containing
filter (e.g., query: {filter: 'x', ...}) the explicit top-level filter parameter
(filter: 'y') wins or is used according to the normalization rule (create a test
named like "query.filter collision prefers canonical filter" that verifies the
expected precedence), and another asserting that query keys with null or
undefined values are omitted from the resulting URL (create a test named like
"omits null/undefined query values") by passing query entries set to
null/undefined and expecting they do not appear in the built URL. Ensure both
tests use buildMembersUrl and assert exact string equality of the generated URL
to lock down behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 40d208a6-5595-4210-9995-bd2cdccd1584

📥 Commits

Reviewing files that changed from the base of the PR and between e3e5e1f and 48ebfcf.

📒 Files selected for processing (39)
  • apps/admin-x-framework/src/api/members.ts
  • apps/posts/src/nql-lang.d.ts
  • apps/posts/src/views/PostAnalytics/Growth/growth.tsx
  • apps/posts/src/views/PostAnalytics/Newsletter/newsletter.tsx
  • apps/posts/src/views/filters/filter-ast.test.ts
  • apps/posts/src/views/filters/filter-ast.ts
  • apps/posts/src/views/filters/filter-codecs.test.ts
  • apps/posts/src/views/filters/filter-codecs.ts
  • apps/posts/src/views/filters/filter-normalization.test.ts
  • apps/posts/src/views/filters/filter-normalization.ts
  • apps/posts/src/views/filters/filter-query-core.test.ts
  • apps/posts/src/views/filters/filter-query-core.ts
  • apps/posts/src/views/filters/filter-types.test.ts
  • apps/posts/src/views/filters/filter-types.ts
  • apps/posts/src/views/filters/resolve-field.test.ts
  • apps/posts/src/views/filters/resolve-field.ts
  • apps/posts/src/views/members/components/bulk-action-modals/remove-label-modal.tsx
  • apps/posts/src/views/members/components/bulk-action-modals/unsubscribe-modal.tsx
  • apps/posts/src/views/members/components/members-actions.tsx
  • apps/posts/src/views/members/components/members-filters.tsx
  • apps/posts/src/views/members/components/members-header.tsx
  • apps/posts/src/views/members/components/members-list-item.tsx
  • apps/posts/src/views/members/components/members-list.tsx
  • apps/posts/src/views/members/hooks/use-members-filter-config.tsx
  • apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx
  • apps/posts/src/views/members/hooks/use-members-filter-state.ts
  • apps/posts/src/views/members/hooks/use-resource-search.ts
  • apps/posts/src/views/members/member-fields.test.ts
  • apps/posts/src/views/members/member-fields.ts
  • apps/posts/src/views/members/member-filter-query.test.ts
  • apps/posts/src/views/members/member-filter-query.ts
  • apps/posts/src/views/members/member-query-params.test.ts
  • apps/posts/src/views/members/member-query-params.ts
  • apps/posts/src/views/members/member-route.test.ts
  • apps/posts/src/views/members/member-route.ts
  • apps/posts/src/views/members/members.tsx
  • apps/posts/src/views/members/use-member-filter-fields.test.ts
  • apps/posts/src/views/members/use-member-filter-fields.ts
  • apps/posts/vitest.config.ts
💤 Files with no reviewable changes (1)
  • apps/posts/src/views/members/hooks/use-members-filter-config.tsx
✅ Files skipped from review due to trivial changes (2)
  • apps/posts/src/views/members/components/members-list-item.tsx
  • apps/posts/src/views/filters/filter-codecs.test.ts
🚧 Files skipped from review as they are similar to previous changes (19)
  • apps/posts/src/views/PostAnalytics/Growth/growth.tsx
  • apps/posts/src/views/members/member-query-params.test.ts
  • apps/posts/src/views/filters/filter-normalization.test.ts
  • apps/posts/src/views/members/components/bulk-action-modals/unsubscribe-modal.tsx
  • apps/posts/src/views/members/member-fields.test.ts
  • apps/posts/src/views/members/member-filter-query.test.ts
  • apps/posts/src/views/filters/filter-normalization.ts
  • apps/posts/src/views/members/member-route.ts
  • apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx
  • apps/posts/src/views/filters/filter-query-core.test.ts
  • apps/posts/src/views/members/use-member-filter-fields.test.ts
  • apps/posts/src/views/members/components/members-list.tsx
  • apps/posts/src/views/filters/filter-ast.test.ts
  • apps/posts/src/views/filters/filter-types.test.ts
  • apps/posts/src/views/filters/resolve-field.ts
  • apps/posts/src/views/filters/resolve-field.test.ts
  • apps/posts/src/views/members/components/members-header.tsx
  • apps/posts/src/views/filters/filter-ast.ts
  • apps/posts/src/views/filters/filter-types.ts

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.

♻️ Duplicate comments (1)
apps/posts/src/views/members/member-filter-query.ts (1)

197-204: ⚠️ Potential issue | 🟠 Major

Unsupported compound fallback still drops clauses instead of failing fast.

Line 203 still routes non-$and compounds into dispatchSimpleNodes, which can omit operator-only nodes. If parseMemberFilter is called without the separate unsupported-check gate, hydration can broaden the filter silently.

Proposed fix (explicitly reject unsupported compounds)
 function parseMemberNode(node: AstNode, timezone: string): ParsedPredicate[] {
     for (const matcher of MEMBER_COMPOUND_MATCHERS) {
         const parsed = matcher(node);

         if (parsed) {
             return [parsed];
         }
     }

     const compound = getCompoundChildren(node);

-    if (compound?.operator === '$and') {
-        return compound.children.flatMap(child => parseMemberNode(child, timezone));
+    if (compound) {
+        if (compound.operator === '$and') {
+            return compound.children.flatMap(child => parseMemberNode(child, timezone));
+        }
+
+        throw new Error(`Unsupported compound operator in member filter parse: ${compound.operator}`);
     }

     return dispatchSimpleNodes([node], memberFields, timezone);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/members/member-filter-query.ts` around lines 197 - 204,
The current fallback routes non-'$and' compound nodes from
getCompoundChildren(node) into dispatchSimpleNodes, which can drop operator-only
clauses; instead, update parseMemberNode (or the surrounding logic that handles
compound) to explicitly reject unsupported compound operators: if compound
exists and compound.operator !== '$and', throw a clear error (e.g., "Unsupported
compound operator: <operator>") rather than calling dispatchSimpleNodes; keep
the existing behavior for operator === '$and' (flatMap parseMemberNode on
children) and only call dispatchSimpleNodes for truly simple/non-compound nodes.
🧹 Nitpick comments (4)
apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx (1)

7-15: Consider using beforeEach/afterEach for settings mock state.

The mutable settingsData variable at module scope can cause test pollution if tests run in parallel or if a test fails before restoring the value. Using beforeEach to set up and afterEach to restore ensures consistent state.

♻️ Suggested safer mock pattern
-let settingsData: {settings: Array<{key: string; value: string}>} | undefined = {
-    settings: [{key: 'timezone', value: 'UTC'}]
-};
+const DEFAULT_SETTINGS = {
+    settings: [{key: 'timezone', value: 'UTC'}]
+};
+
+let settingsData: {settings: Array<{key: string; value: string}>} | undefined;
+
+beforeEach(() => {
+    settingsData = DEFAULT_SETTINGS;
+});

Then in the specific test that needs undefined settings:

 it('preserves raw date filters while settings are unresolved', () => {
     settingsData = undefined;
     // ... test code ...
-
-    settingsData = {
-        settings: [{key: 'timezone', value: 'UTC'}]
-    };
+    // No need to restore - beforeEach handles it
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx` around
lines 7 - 15, The module-scoped mutable settingsData and the vi.mock for
useBrowseSettings can cause test pollution; change the mock setup so tests
initialize settingsData in a beforeEach (e.g., set settingsData = {settings:
[...]} or undefined as needed) and restore/reset it in an afterEach (or call
vi.resetModules()/vi.clearAllMocks() there) rather than relying on a top-level
variable, keeping the vi.mock('@tryghost/admin-x-framework/api/settings')
implementation to read the runtime settingsData value so individual tests can
safely override it.
apps/posts/src/views/members/hooks/resource-search-filter.test.ts (1)

5-13: Consider adding test for standard search case.

The tests cover empty search and escaping edge cases, but a basic test for a normal search term would improve coverage and serve as documentation.

💚 Suggested additional test
     it('escapes trailing backslashes in search queries', () => {
         expect(buildResourceFilter('status:published', 'draft\\')).toBe('status:published+title:~\'draft\\\\\'');
     });
+
+    it('appends title filter for non-empty search', () => {
+        expect(buildResourceFilter('status:published', 'hello')).toBe('status:published+title:~\'hello\'');
+    });
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/members/hooks/resource-search-filter.test.ts` around
lines 5 - 13, Add a basic test that verifies buildResourceFilter returns the
base filter combined with a simple (non-escaping) search term: update
apps/posts/src/views/members/hooks/resource-search-filter.test.ts to include an
it block that calls buildResourceFilter('status:published', 'draft') and asserts
it equals "status:published+title:~'draft'"; reference the buildResourceFilter
function and the existing test file so the new test mirrors the style of the
empty-search and trailing-backslash tests.
apps/posts/src/views/members/hooks/resource-search-filter.ts (1)

3-9: Handle empty baseFilter edge case.

If baseFilter is an empty string, the function will produce +title:~'search' which starts with an invalid + operator. Consider guarding against this or documenting the assumption that baseFilter is always non-empty.

♻️ Suggested defensive handling
 export function buildResourceFilter(baseFilter: string, search: string): string {
     if (!search) {
         return baseFilter;
     }

+    if (!baseFilter) {
+        return `title:~${escapeNqlString(search)}`;
+    }
+
     return `${baseFilter}+title:~${escapeNqlString(search)}`;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/members/hooks/resource-search-filter.ts` around lines 3
- 9, The buildResourceFilter function can produce an invalid leading '+' when
baseFilter is empty; update buildResourceFilter to guard for empty or
whitespace-only baseFilter (check baseFilter.trim() or falsy) and return only
the title clause `${escapeNqlString(search)}` (i.e., `title:~...`) without a
leading '+' in that case; otherwise keep the existing `${baseFilter}+title:~...`
behavior so callers of buildResourceFilter and the escapeNqlString helper are
handled correctly.
apps/posts/src/views/members/member-fields.ts (1)

152-165: Unusual use of operator as a data value.

The feedbackCodec uses predicate.operator as the feedback score ('1' or '0') rather than as an actual operator. This works but is semantically confusing since operators are expected to describe comparisons (e.g., is, is-not), not data values.

Consider documenting this design choice or refactoring to use a more conventional pattern where the score is in values and the operator is a standard comparison.

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

In `@apps/posts/src/views/members/member-fields.ts` around lines 152 - 165, The
serialize function in feedbackCodec is misusing predicate.operator as the
feedback score; change the shape so the score is carried in predicate.values
(e.g., values[0] = postId, values[1] = score) and treat predicate.operator as a
real comparison token (validate it against expected operators like 'is'/'is-not'
or '='/'!='), update feedbackCodec.serialize to read and validate postId from
predicate.values[0] and score from predicate.values[1] (and return null if
invalid), and adjust feedbackCodec.parse to produce that same shape so callers
set the score in values rather than operator.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@apps/posts/src/views/members/member-filter-query.ts`:
- Around line 197-204: The current fallback routes non-'$and' compound nodes
from getCompoundChildren(node) into dispatchSimpleNodes, which can drop
operator-only clauses; instead, update parseMemberNode (or the surrounding logic
that handles compound) to explicitly reject unsupported compound operators: if
compound exists and compound.operator !== '$and', throw a clear error (e.g.,
"Unsupported compound operator: <operator>") rather than calling
dispatchSimpleNodes; keep the existing behavior for operator === '$and' (flatMap
parseMemberNode on children) and only call dispatchSimpleNodes for truly
simple/non-compound nodes.

---

Nitpick comments:
In `@apps/posts/src/views/members/hooks/resource-search-filter.test.ts`:
- Around line 5-13: Add a basic test that verifies buildResourceFilter returns
the base filter combined with a simple (non-escaping) search term: update
apps/posts/src/views/members/hooks/resource-search-filter.test.ts to include an
it block that calls buildResourceFilter('status:published', 'draft') and asserts
it equals "status:published+title:~'draft'"; reference the buildResourceFilter
function and the existing test file so the new test mirrors the style of the
empty-search and trailing-backslash tests.

In `@apps/posts/src/views/members/hooks/resource-search-filter.ts`:
- Around line 3-9: The buildResourceFilter function can produce an invalid
leading '+' when baseFilter is empty; update buildResourceFilter to guard for
empty or whitespace-only baseFilter (check baseFilter.trim() or falsy) and
return only the title clause `${escapeNqlString(search)}` (i.e., `title:~...`)
without a leading '+' in that case; otherwise keep the existing
`${baseFilter}+title:~...` behavior so callers of buildResourceFilter and the
escapeNqlString helper are handled correctly.

In `@apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx`:
- Around line 7-15: The module-scoped mutable settingsData and the vi.mock for
useBrowseSettings can cause test pollution; change the mock setup so tests
initialize settingsData in a beforeEach (e.g., set settingsData = {settings:
[...]} or undefined as needed) and restore/reset it in an afterEach (or call
vi.resetModules()/vi.clearAllMocks() there) rather than relying on a top-level
variable, keeping the vi.mock('@tryghost/admin-x-framework/api/settings')
implementation to read the runtime settingsData value so individual tests can
safely override it.

In `@apps/posts/src/views/members/member-fields.ts`:
- Around line 152-165: The serialize function in feedbackCodec is misusing
predicate.operator as the feedback score; change the shape so the score is
carried in predicate.values (e.g., values[0] = postId, values[1] = score) and
treat predicate.operator as a real comparison token (validate it against
expected operators like 'is'/'is-not' or '='/'!='), update
feedbackCodec.serialize to read and validate postId from predicate.values[0] and
score from predicate.values[1] (and return null if invalid), and adjust
feedbackCodec.parse to produce that same shape so callers set the score in
values rather than operator.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 79b94f9b-cc0c-4f5f-bd22-1cf9f94954c6

📥 Commits

Reviewing files that changed from the base of the PR and between 48ebfcf and 8017c07.

📒 Files selected for processing (14)
  • apps/posts/src/views/PostAnalytics/Newsletter/newsletter.tsx
  • apps/posts/src/views/filters/filter-codecs.test.ts
  • apps/posts/src/views/filters/filter-codecs.ts
  • apps/posts/src/views/members/hooks/resource-search-filter.test.ts
  • apps/posts/src/views/members/hooks/resource-search-filter.ts
  • apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx
  • apps/posts/src/views/members/hooks/use-members-filter-state.ts
  • apps/posts/src/views/members/hooks/use-resource-search.ts
  • apps/posts/src/views/members/member-fields.test.ts
  • apps/posts/src/views/members/member-fields.ts
  • apps/posts/src/views/members/member-filter-query.ts
  • apps/posts/src/views/members/member-route.test.ts
  • apps/posts/src/views/members/member-route.ts
  • apps/posts/src/views/members/members.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/posts/src/views/members/member-fields.test.ts
  • apps/posts/src/views/members/member-route.ts
  • apps/posts/src/views/members/member-route.test.ts

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: 8017c07a5b

ℹ️ 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".

jonatansberg and others added 17 commits March 18, 2026 14:14
ref https://linear.app/ghost/issue/BER-3414
Replaced the fragmented React filter implementation with the clean-room codec-based flow while leaving planning docs out of branch history
ref https://linear.app/ghost/issue/BER-3414
Replaced the codec fallback loop with explicit field parse aliases, shared the AST node type, removed duplicate predicate stamping, and tightened small consistency issues from review
ref https://linear.app/ghost/issue/BER-3414
Dropped shallow UI tests in favor of keeping coverage at the pure logic and hook layers, leaving UI behavior for the e2e suite
ref https://linear.app/ghost/issue/BER-3414
Reduced the repeated member compound matching loops so the member filter query stays explicit without carrying duplicated control flow.
ref https://linear.app/ghost/issue/BER-3414
Kept grouped retention options while scoping value translation to the offer field only
ref https://linear.app/ghost/issue/BER-3414
Removed duplicated operator label wiring from members and comments hydration hooks
ref https://linear.app/ghost/issue/BER-3414
Matched Ember's recursive compound parsing shape instead of reconstructing compounds from flattened leaves
ref https://linear.app/ghost/issue/BER-3414
Matched the recursive compound parsing model across comments and members and removed dead AST flattening helpers
…app/ghost/issue/BER-3414\nMoved subscribed legacy parsing fully into the member query layer and removed repeated hydration overrides in the member filter field hook
ref https://linear.app/ghost/issue/BER-3414
Cleared apps/posts lint fallout and added the local nql-lang declaration so the filter rewrite branch can build under CI's posts target.
ref https://linear.app/ghost/issue/BER-3414

Restored the comments surface to origin/main so the base branch only carries the shared filter core, the members rewrite, and the member-side API changes.
ref https://linear.app/ghost/issue/BER-3414

The previous revert commit inadvertently left Tailwind class reordering
in comment components. This restores them to main so they produce zero
diff and won't be linted in CI.
ref https://linear.app/ghost/issue/BER-3414

Moved canonicalizeClauses, normalizeMultiValue, getDayBoundsInUtc, and
createOperatorOptions into their single consumer modules as unexported
functions. Removed dead code normalizeOperator and deleted the
filter-operator-options module. filter-normalization.ts now only exports
escapeNqlString, which is the only genuinely shared utility.
ref https://linear.app/ghost/issue/BER-3414

Canonicalized analytics-to-members links to use the members route's filter query param directly instead of relying on Ember's filterParam aliasing. Restored scalar set-filter parsing and singleton offer serialization so existing member deep links like offer_redemptions:'id' continue to round-trip through the React filter pipeline.
ref https://linear.app/ghost/issue/BER-3414

Applied the Node 22 CI lint autofix to the newsletter analytics view on the base branch so the stacked branches stop inheriting this package-lint failure.
ref https://linear.app/ghost/issue/BER-3414
Members filters now sync immediately to URL state, so the codec and field schema need to preserve editable text and number values while restoring missing legacy member filter options.
ref https://linear.app/ghost/issue/BER-3414

Unsupported OR compounds now stay as raw member NQL so the filters UI does not partially represent queries it cannot edit safely.
ref https://linear.app/ghost/issue/BER-3414

The members filters state no longer exposes an unused functional setFilters overload, which removes a stale API path that could evaluate against stale URL-backed state.
ref https://linear.app/ghost/issue/BER-3414

The members empty state now uses neutral wording so it reads correctly for both filter and search-driven empty results.
ref https://linear.app/ghost/issue/BER-3414

The restored subscription status options now satisfy the posts field config types, and the touched posts views use the Tailwind class ordering required by the posts package lint.
ref https://linear.app/ghost/issue/BER-3414
Adjusted the members actions usage and unit test props so the rebased stack still passes Posts type-check and lint.
@jonatansberg jonatansberg force-pushed the ber-3414-rework-filters-for-members-only branch from 8017c07 to 929e525 Compare March 18, 2026 14:16
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

♻️ Duplicate comments (2)
apps/admin-x-framework/src/api/members.ts (1)

199-219: ⚠️ Potential issue | 🟠 Major

Make all mutually exclusive with filter/search in bulk params.

Line 206 currently allows all to be sent together with filter and/or search (Lines 210-216). For bulk edit/delete this creates ambiguous scope and can widen destructive operations depending on backend precedence. Fail closed here.

Proposed fix
 function buildBulkMemberSearchParams({filter, search, all}: {filter?: string; search?: string; all?: boolean}) {
+    if (all && (filter || search)) {
+        throw new Error('Bulk operation cannot combine all with filter or search');
+    }
+
     if (!all && !filter && !search) {
         throw new Error('Bulk operation requires a filter, search, or all flag');
     }
 
     const params: Record<string, string> = {};
 
     if (all) {
         params.all = 'true';
+        return params;
     }
 
     if (filter) {
         params.filter = filter;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-x-framework/src/api/members.ts` around lines 199 - 219, The
function buildBulkMemberSearchParams currently allows the all flag to be
combined with filter/search; update it to fail when all is true alongside filter
or search to make the options mutually exclusive. Inside
buildBulkMemberSearchParams, add a guard that throws an Error if all && (filter
|| search) before building params, preserving the existing empty-check (if !all
&& !filter && !search) and then continue to set params.all, params.filter,
params.search as before. Reference: buildBulkMemberSearchParams, and the
parameters all, filter, search.
apps/posts/src/views/members/use-member-filter-fields.ts (1)

402-410: ⚠️ Potential issue | 🟡 Minor

email_opened_count should be guarded by emailTrackOpens.

The email_opened_count field is added whenever emailAnalyticsEnabled is true, but it tracks email opens, which requires open tracking to be enabled. This is inconsistent with email_open_rate and opened_emails.post_id, which are correctly guarded by emailTrackOpens.

🔧 Suggested fix
         if (emailAnalyticsEnabled) {
             const emailFields: FilterFieldConfig[] = [
-                createFieldConfig('email_count'),
-                createFieldConfig('email_opened_count')
+                createFieldConfig('email_count')
             ];

             if (emailTrackOpens) {
-                emailFields.push(createFieldConfig('email_open_rate'));
+                emailFields.push(
+                    createFieldConfig('email_opened_count'),
+                    createFieldConfig('email_open_rate')
+                );
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/members/use-member-filter-fields.ts` around lines 402 -
410, The email_opened_count field is currently added whenever
emailAnalyticsEnabled is true but it depends on open tracking; modify the block
inside the emailAnalyticsEnabled branch so that
createFieldConfig('email_opened_count') is only pushed when emailTrackOpens is
true (same guard used for createFieldConfig('email_open_rate') and
opened_emails.post_id). Locate the emailFields array and move or wrap the
createFieldConfig('email_opened_count') addition with an if (emailTrackOpens)
check so both email_opened_count and email_open_rate are added only when
emailTrackOpens is true.
🧹 Nitpick comments (2)
apps/posts/src/views/members/components/members-actions.tsx (1)

61-61: Consider memoizing memberOperationParams to stabilize callback references.

memberOperationParams is recreated on every render, which causes all dependent callbacks (handleAddLabel, handleRemoveLabel, handleUnsubscribe, handleDelete) to be recreated even when nql and search haven't changed. This may trigger unnecessary re-renders in child components.

♻️ Proposed fix using useMemo
+    const memberOperationParams = useMemo(
+        () => buildMemberOperationParams({nql, search}),
+        [nql, search]
+    );
-    const memberOperationParams = buildMemberOperationParams({nql, search});

Don't forget to add useMemo to the React import at line 1.

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

In `@apps/posts/src/views/members/components/members-actions.tsx` at line 61,
memberOperationParams is recreated each render causing dependent callbacks
(handleAddLabel, handleRemoveLabel, handleUnsubscribe, handleDelete) to be
unstable; wrap the buildMemberOperationParams call in React.useMemo (or useMemo
after importing it) so it only recomputes when nql or search change: replace
const memberOperationParams = buildMemberOperationParams({nql, search}); with a
useMemo that calls buildMemberOperationParams and lists [nql, search] as
dependencies so the callbacks retain stable references.
apps/posts/src/views/members/member-fields.ts (1)

152-165: Consider using semantic operator names for feedback codec.

The newsletter_feedback field uses '1' and '0' as operator names (line 453), which are unconventional. While this works functionally, using semantic names like 'positive' and 'negative' (or 'like' and 'dislike') would improve readability and maintainability.

♻️ Optional refactor for clarity
 const feedbackCodec: FilterCodec = {
     parse() {
         return null;
     },
     serialize(predicate) {
         const postId = predicate.values[0];

-        if (typeof postId !== 'string' || !postId || (predicate.operator !== '1' && predicate.operator !== '0')) {
+        if (typeof postId !== 'string' || !postId || (predicate.operator !== 'positive' && predicate.operator !== 'negative')) {
             return null;
         }

-        return [`(feedback.post_id:${escapeNqlString(postId)}+feedback.score:${predicate.operator})`];
+        const score = predicate.operator === 'positive' ? '1' : '0';
+        return [`(feedback.post_id:${escapeNqlString(postId)}+feedback.score:${score})`];
     }
 };

And update the field definition:

     newsletter_feedback: {
-        operators: ['1', '0'],
+        operators: ['positive', 'negative'],
         ui: {
             label: 'Responded with feedback',
             type: 'select',
             searchable: true,
             placeholder: 'Select an email...',
             className: 'w-64',
-            defaultOperator: '1'
+            defaultOperator: 'positive'
         },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/members/member-fields.ts` around lines 152 - 165, The
feedbackCodec currently uses numeric operator strings ('1' and '0') in serialize
to determine feedback.score; update it to use semantic operator names (e.g.,
'positive' and 'negative' or 'like' and 'dislike') by changing the operator
checks in feedbackCodec.serialize to accept and map those names to the
corresponding score values, and update any related field definition
(newsletter_feedback) to emit the new operator names instead of '1'/'0' so the
codec and field remain consistent; adjust any consumer code that constructs
predicates to use the new operator tokens.
🤖 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/posts/src/views/members/hooks/use-members-filter-state.test.tsx`:
- Around line 7-10: The shared mutable variable settingsData is mutated during a
test which can leak state to later tests; fix by resetting or reinitializing
settingsData in a beforeEach (or creating an immutable factory and assigning
settingsData = createDefaultSettings() inside beforeEach) so every test gets a
fresh {settings: [{key: 'timezone', value: 'UTC'}]} instance; reference the
settingsData variable in use-members-filter-state.test.tsx and ensure any test
that mutates it does not rely on teardown to restore state.

---

Duplicate comments:
In `@apps/admin-x-framework/src/api/members.ts`:
- Around line 199-219: The function buildBulkMemberSearchParams currently allows
the all flag to be combined with filter/search; update it to fail when all is
true alongside filter or search to make the options mutually exclusive. Inside
buildBulkMemberSearchParams, add a guard that throws an Error if all && (filter
|| search) before building params, preserving the existing empty-check (if !all
&& !filter && !search) and then continue to set params.all, params.filter,
params.search as before. Reference: buildBulkMemberSearchParams, and the
parameters all, filter, search.

In `@apps/posts/src/views/members/use-member-filter-fields.ts`:
- Around line 402-410: The email_opened_count field is currently added whenever
emailAnalyticsEnabled is true but it depends on open tracking; modify the block
inside the emailAnalyticsEnabled branch so that
createFieldConfig('email_opened_count') is only pushed when emailTrackOpens is
true (same guard used for createFieldConfig('email_open_rate') and
opened_emails.post_id). Locate the emailFields array and move or wrap the
createFieldConfig('email_opened_count') addition with an if (emailTrackOpens)
check so both email_opened_count and email_open_rate are added only when
emailTrackOpens is true.

---

Nitpick comments:
In `@apps/posts/src/views/members/components/members-actions.tsx`:
- Line 61: memberOperationParams is recreated each render causing dependent
callbacks (handleAddLabel, handleRemoveLabel, handleUnsubscribe, handleDelete)
to be unstable; wrap the buildMemberOperationParams call in React.useMemo (or
useMemo after importing it) so it only recomputes when nql or search change:
replace const memberOperationParams = buildMemberOperationParams({nql, search});
with a useMemo that calls buildMemberOperationParams and lists [nql, search] as
dependencies so the callbacks retain stable references.

In `@apps/posts/src/views/members/member-fields.ts`:
- Around line 152-165: The feedbackCodec currently uses numeric operator strings
('1' and '0') in serialize to determine feedback.score; update it to use
semantic operator names (e.g., 'positive' and 'negative' or 'like' and
'dislike') by changing the operator checks in feedbackCodec.serialize to accept
and map those names to the corresponding score values, and update any related
field definition (newsletter_feedback) to emit the new operator names instead of
'1'/'0' so the codec and field remain consistent; adjust any consumer code that
constructs predicates to use the new operator tokens.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 14a774e3-de30-419d-9f16-1c4f42155277

📥 Commits

Reviewing files that changed from the base of the PR and between 8017c07 and 929e525.

📒 Files selected for processing (42)
  • apps/admin-x-framework/src/api/members.ts
  • apps/posts/src/nql-lang.d.ts
  • apps/posts/src/views/PostAnalytics/Growth/growth.tsx
  • apps/posts/src/views/PostAnalytics/Newsletter/newsletter.tsx
  • apps/posts/src/views/filters/filter-ast.test.ts
  • apps/posts/src/views/filters/filter-ast.ts
  • apps/posts/src/views/filters/filter-codecs.test.ts
  • apps/posts/src/views/filters/filter-codecs.ts
  • apps/posts/src/views/filters/filter-normalization.test.ts
  • apps/posts/src/views/filters/filter-normalization.ts
  • apps/posts/src/views/filters/filter-query-core.test.ts
  • apps/posts/src/views/filters/filter-query-core.ts
  • apps/posts/src/views/filters/filter-types.test.ts
  • apps/posts/src/views/filters/filter-types.ts
  • apps/posts/src/views/filters/resolve-field.test.ts
  • apps/posts/src/views/filters/resolve-field.ts
  • apps/posts/src/views/members/components/bulk-action-modals/remove-label-modal.tsx
  • apps/posts/src/views/members/components/bulk-action-modals/unsubscribe-modal.tsx
  • apps/posts/src/views/members/components/members-actions.tsx
  • apps/posts/src/views/members/components/members-filters.tsx
  • apps/posts/src/views/members/components/members-header.tsx
  • apps/posts/src/views/members/components/members-list-item.tsx
  • apps/posts/src/views/members/components/members-list.tsx
  • apps/posts/src/views/members/hooks/resource-search-filter.test.ts
  • apps/posts/src/views/members/hooks/resource-search-filter.ts
  • apps/posts/src/views/members/hooks/use-members-filter-config.tsx
  • apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx
  • apps/posts/src/views/members/hooks/use-members-filter-state.ts
  • apps/posts/src/views/members/hooks/use-resource-search.ts
  • apps/posts/src/views/members/member-fields.test.ts
  • apps/posts/src/views/members/member-fields.ts
  • apps/posts/src/views/members/member-filter-query.test.ts
  • apps/posts/src/views/members/member-filter-query.ts
  • apps/posts/src/views/members/member-query-params.test.ts
  • apps/posts/src/views/members/member-query-params.ts
  • apps/posts/src/views/members/member-route.test.ts
  • apps/posts/src/views/members/member-route.ts
  • apps/posts/src/views/members/members.tsx
  • apps/posts/src/views/members/use-member-filter-fields.test.ts
  • apps/posts/src/views/members/use-member-filter-fields.ts
  • apps/posts/test/unit/views/members/members-actions.test.tsx
  • apps/posts/vitest.config.ts
💤 Files with no reviewable changes (1)
  • apps/posts/src/views/members/hooks/use-members-filter-config.tsx
✅ Files skipped from review due to trivial changes (1)
  • apps/posts/src/views/members/components/members-header.tsx
🚧 Files skipped from review as they are similar to previous changes (20)
  • apps/posts/src/nql-lang.d.ts
  • apps/posts/src/views/members/member-fields.test.ts
  • apps/posts/src/views/PostAnalytics/Newsletter/newsletter.tsx
  • apps/posts/src/views/members/member-route.ts
  • apps/posts/src/views/filters/filter-normalization.ts
  • apps/posts/src/views/members/hooks/resource-search-filter.ts
  • apps/posts/src/views/members/components/bulk-action-modals/unsubscribe-modal.tsx
  • apps/posts/src/views/members/components/members-list.tsx
  • apps/posts/vitest.config.ts
  • apps/posts/src/views/PostAnalytics/Growth/growth.tsx
  • apps/posts/src/views/members/hooks/resource-search-filter.test.ts
  • apps/posts/src/views/filters/filter-ast.ts
  • apps/posts/src/views/members/member-filter-query.ts
  • apps/posts/src/views/filters/filter-query-core.ts
  • apps/posts/src/views/filters/filter-types.test.ts
  • apps/posts/src/views/members/member-route.test.ts
  • apps/posts/src/views/members/hooks/use-resource-search.ts
  • apps/posts/src/views/members/use-member-filter-fields.test.ts
  • apps/posts/src/views/members/components/members-list-item.tsx
  • apps/posts/src/views/members/member-query-params.test.ts

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: 929e52535b

ℹ️ 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".

@jonatansberg jonatansberg added the preview Deploy a PR preview environment label Mar 19, 2026
@Ghost-Slimer Ghost-Slimer temporarily deployed to pr-preview-26828 March 19, 2026 06:57 Destroyed
ref https://linear.app/ghost/issue/BER-3414
Legacy Ember member date URLs encode UTC timestamps without a timezone suffix, so non-UTC sites parsed them as local dates and shifted saved segments.
ref https://linear.app/ghost/issue/BER-3414
Members views should block stale refetch interactions and fail closed on bulk delete when only preserved raw filters are available.
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 (2)
apps/posts/src/views/members/members-view-state.test.ts (1)

4-16: Consider expanding test coverage.

The current tests cover only two specific scenarios. Additional edge cases would strengthen confidence:

  • shouldShowMembersLoading: test when isFetchingNextPage is true (should return false)
  • canBulkDeleteMembers: test with filters containing restricted fields, test with filters containing only allowed fields
🧪 Additional test cases
it('hides loading state during pagination fetches', () => {
    expect(shouldShowMembersLoading({
        isFetching: true,
        isFetchingNextPage: true
    })).toBe(false);
});

it('allows bulk delete when no restricted filters are present', () => {
    expect(canBulkDeleteMembers([{field: 'name'}])).toBe(true);
});

it('disables bulk delete when a restricted filter field is present', () => {
    expect(canBulkDeleteMembers([{field: 'subscriptions.status'}])).toBe(false);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/members/members-view-state.test.ts` around lines 4 - 16,
Add the missing edge-case tests for shouldShowMembersLoading and
canBulkDeleteMembers: add a test calling shouldShowMembersLoading with
isFetching: true and isFetchingNextPage: true (and optionally other flags) and
assert it returns false; add a test calling canBulkDeleteMembers with filters
that contain only allowed fields (e.g., [{field: 'name'}]) and assert true; and
add a test calling canBulkDeleteMembers with a filter containing a restricted
field (e.g., 'subscriptions.status') and assert false; place these tests
alongside existing ones in members-view-state.test.ts referencing the same
helper functions shouldShowMembersLoading and canBulkDeleteMembers.
apps/posts/src/views/members/members.tsx (1)

149-149: Inconsistent loading logic with centralized helper.

The isLoading prop uses inline logic isFetching && !isFetchingNextPage, while lines 50-55 use shouldShowMembersLoading for the same purpose. Consider using the centralized helper here as well for consistency.

-                        isLoading={isFetching && !isFetchingNextPage}
+                        isLoading={shouldShowLoading}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/members/members.tsx` at line 149, The isLoading prop
currently uses inline logic (isFetching && !isFetchingNextPage) which duplicates
logic already centralized in shouldShowMembersLoading; replace the inline
expression with the centralized helper by passing shouldShowMembersLoading to
the isLoading prop so the component uses the same loading decision as the rest
of the file (ensure you reference the existing shouldShowMembersLoading variable
instead of isFetching/isFetchingNextPage directly).
🤖 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/posts/src/views/members/member-fields.ts`:
- Around line 138-156: The serialize function is interpolating ctx.params.slug
directly into NQL which can produce malformed queries for slugs with special
characters; update member-fields.ts to import and use the existing
escapeNqlString utility (as used in feedbackCodec) and substitute escapedSlug =
escapeNqlString(slug) before constructing the two return strings in serialize
(for both 'subscribed' and 'unsubscribed' branches), ensuring you apply the
escaped value in the `(newsletters.slug:...)` and `newsletters.slug:-...`
patterns.

In `@apps/posts/src/views/members/members-view-state.ts`:
- Around line 22-29: The function signature for shouldShowMembersLoading
currently only accepts {isFetching, isFetchingNextPage} but call sites
(members.tsx and the related test) pass additional properties isRefetching and
memberCount causing TypeScript excess property errors; either expand the
parameter type to include isRefetching?: boolean and memberCount?: number (and
update the function body only if those values should affect logic) or leave the
logic unchanged and update all call sites to pass only { isFetching,
isFetchingNextPage }; locate the function shouldShowMembersLoading and the call
sites in members.tsx and the test to apply the chosen fix consistently.

---

Nitpick comments:
In `@apps/posts/src/views/members/members-view-state.test.ts`:
- Around line 4-16: Add the missing edge-case tests for shouldShowMembersLoading
and canBulkDeleteMembers: add a test calling shouldShowMembersLoading with
isFetching: true and isFetchingNextPage: true (and optionally other flags) and
assert it returns false; add a test calling canBulkDeleteMembers with filters
that contain only allowed fields (e.g., [{field: 'name'}]) and assert true; and
add a test calling canBulkDeleteMembers with a filter containing a restricted
field (e.g., 'subscriptions.status') and assert false; place these tests
alongside existing ones in members-view-state.test.ts referencing the same
helper functions shouldShowMembersLoading and canBulkDeleteMembers.

In `@apps/posts/src/views/members/members.tsx`:
- Line 149: The isLoading prop currently uses inline logic (isFetching &&
!isFetchingNextPage) which duplicates logic already centralized in
shouldShowMembersLoading; replace the inline expression with the centralized
helper by passing shouldShowMembersLoading to the isLoading prop so the
component uses the same loading decision as the rest of the file (ensure you
reference the existing shouldShowMembersLoading variable instead of
isFetching/isFetchingNextPage directly).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ff021646-b860-42c5-a34c-8d1142e3963b

📥 Commits

Reviewing files that changed from the base of the PR and between 929e525 and 5370206.

📒 Files selected for processing (5)
  • apps/posts/src/views/members/member-fields.ts
  • apps/posts/src/views/members/member-filter-query.test.ts
  • apps/posts/src/views/members/members-view-state.test.ts
  • apps/posts/src/views/members/members-view-state.ts
  • apps/posts/src/views/members/members.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/posts/src/views/members/member-filter-query.test.ts

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: 5370206da8

ℹ️ 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".

ref https://linear.app/ghost/issue/BER-3414
Date filters should stay raw until the site timezone is known, and the members loading helper should match its simplified non-pagination contract.
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: 2a7c4d209f

ℹ️ 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".

ref https://linear.app/ghost/issue/BER-3414
Unsupported member OR clauses should be discarded instead of preserved through the hook, while unresolved date filters still need to survive URL rewrites until the site timezone is known.
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: b4fb07b059

ℹ️ 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".

Comment on lines +82 to +85
hasFilterOrSearch={hasFilterOrSearch}
memberCount={totalMembers}
nql={nql}
search={search}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Surface search-only member scopes in the header UI

Now that search is threaded into the members query and bulk/export actions, opening /members?search=... produces a genuinely scoped view, but this screen still derives its visible filter/reset state from filters.length only. On the current React members page there is no separate search control, so a search-only URL narrows the list and scopes destructive actions without showing any active chip/input or a way to clear that scope unless the result set is empty. The old members UX treated search as first-class state (ghost/admin/tests/acceptance/members/filter-test.js:1592-1626), so this becomes a confusing and easy-to-miss regression for shared/bookmarked search links.

Useful? React with 👍 / 👎.

Comment on lines +45 to +48
const compound = getCompoundChildren(node);

if (!compound || compound.children.length !== 2) {
return null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle plain subscribed:true|false member filters

The members API still accepts simple NQL like subscribed:true and subscribed:false (ghost/core/test/e2e-api/admin/members-newsletters.test.js:79-99), but this parser only recognizes the compound (subscribed...+email_disabled...) shapes. Because the simple-node fallback also has no subscribed parser, opening a URL such as /members?filter=subscribed:true now hydrates to filters=[]/nql=undefined, so the list and bulk actions silently fall back to the full audience instead of the subscribed segment.

Useful? React with 👍 / 👎.

ref https://linear.app/ghost/issue/BER-3414
Members filters now parse best-effort from the URL and canonicalize unsupported clauses away, while the page waits for site settings before hydrating timezone-sensitive date filters.
ref https://linear.app/ghost/issue/BER-3414
Best-effort member filter parsing now canonicalizes legacy subscribed booleans through the existing subscribed filter model.
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: af3321313c

ℹ️ 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".

Comment on lines +345 to +349
if (activeNewsletters.length > 1) {
const newsletterFields: FilterFieldConfig[] = [
createFieldConfig('subscribed', {
label: 'All newsletters',
options: [
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep hydrated newsletter filters visible with one active newsletter

If a user opens a saved segment like ?filter=newsletters.slug:weekly+email_disabled:0 after the site is down to a single active newsletter, this branch stops registering any newsletters.{slug} field and only exposes the generic subscribed filter. useMembersFilterState() still parses and applies the newsletter-specific NQL, but Shade silently drops filters whose field config is missing (apps/shade/src/components/ui/filters.tsx:1849-1852), so the list/export/delete remain scoped with no visible chip or way to clear that constraint. The same hidden-scope problem also occurs for archived newsletters because members-filters.tsx filters them out before building the field map.

Useful? React with 👍 / 👎.

Comment on lines +164 to +166
const shouldDelayHydration = shouldDelayMembersDateFilterHydration(filterParam, Boolean(settingsData));

if (shouldDelayHydration) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid indefinite loading when settings fetch fails for date URLs

For any members URL containing a date predicate, shouldDelayMembersDateFilterHydration() stays true until settingsData becomes truthy. If the settings request errors or never resolves, this component never reaches the normal members error/render path and just keeps showing the loading spinner, so bookmarked views like ?filter=created_at:<=... become unusable during a transient settings failure. This needs an isError/isLoading check or a fallback path instead of gating solely on Boolean(settingsData).

Useful? React with 👍 / 👎.

ref https://linear.app/ghost/issue/BER-3414
Hydrated newsletter filters now stay visible when their runtime field config would otherwise disappear, and date-filter loading only blocks while site settings are still actively loading.
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: b0951ac992

ℹ️ 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".

Comment on lines +27 to +31
if (typeof node.subscribed === 'boolean') {
return {
field: 'subscribed',
operator: 'is',
values: [node.subscribed ? 'subscribed' : 'unsubscribed']
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve semantics of raw subscribed:true|false URLs

Fresh evidence: matchSubscribedNode() now hydrates plain subscribed:true|false, but useMembersFilterState() immediately canonicalizes those predicates through subscribedCodec.serialize() into (subscribed:true+email_disabled:0) / (subscribed:false+email_disabled:0). The backend still treats raw subscribed as a pure newsletter-status alias (ghost/core/core/server/api/endpoints/utils/serializers/input/members.js:18-31), so opening an existing URL like ?filter=subscribed:false now silently narrows the segment by excluding email-disabled members before any list/export/delete action runs.

Useful? React with 👍 / 👎.

return compound.children.flatMap(child => parseMemberNode(child, timezone));
}

return dispatchSimpleNodes([node], memberFields, timezone);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve backend member-filter aliases during hydration

The simple-node fallback only understands keys registered in memberFields, so backend-supported aliases such as ?filter=newsletters:weekly-newsletter and ?filter=tier:default-product fall through here and parse as no filters. On the old members route those raw aliases were still passed through to the API (ghost/admin/app/controllers/members.js:298-333), but the new hook rewrites an empty parse result to no filter, so the list and bulk actions broaden to the full audience. Ghost still accepts these aliases (ghost/core/core/server/models/member.js:61-84, ghost/core/test/e2e-api/admin/members.test.js:2707-2726).

Useful? React with 👍 / 👎.

@jonatansberg jonatansberg added preview Deploy a PR preview environment and removed preview Deploy a PR preview environment labels Mar 19, 2026
ref https://linear.app/ghost/issue/BER-3414
Avoid invalid empty-string NQL by keeping incomplete text filters local until they can be serialized.
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: c7609201f6

ℹ️ 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".

Comment on lines +73 to +76
useEffect(() => {
if (currentQuery !== lastWrittenQueryRef.current) {
setDraftFilters(parsedFilters);
lastWrittenQueryRef.current = currentQuery;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Rehydrate local date filters after timezone recovery

When the page first falls back to UTC because settings data is absent but not loading (for example after a transient settings error) and later rerenders with the real site timezone, parsedFilters changes but this guard does not copy it into filters because the query string is unchanged. The next effect then serializes the stale UTC-derived day under the new timezone, rewriting filter= to the wrong date and retargeting the members list, export, and bulk actions on non-UTC sites.

Useful? React with 👍 / 👎.

@kevinansfield kevinansfield merged commit 207c302 into main Mar 19, 2026
32 checks passed
@kevinansfield kevinansfield deleted the ber-3414-rework-filters-for-members-only branch March 19, 2026 12:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

preview Deploy a PR preview environment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants