feat(products): add catalog GraphQL - #6099
Merged
Merged
Conversation
This was referenced Aug 7, 2026
rsempe
force-pushed
the
products-and-plans-catalog-services
branch
from
August 7, 2026 15:17
4c425be to
f69a6b5
Compare
rsempe
force-pushed
the
products-and-plans-catalog-graphql
branch
from
August 7, 2026 15:17
9c9e2c2 to
588ee73
Compare
endenis
approved these changes
Aug 10, 2026
rsempe
force-pushed
the
products-and-plans-catalog-graphql
branch
from
August 10, 2026 09:10
7e1d06a to
bd7d671
Compare
rsempe
force-pushed
the
products-and-plans-catalog-services
branch
from
August 10, 2026 09:10
f69a6b5 to
0fe1e02
Compare
rsempe
force-pushed
the
products-and-plans-catalog-graphql
branch
from
August 10, 2026 09:23
bd7d671 to
4823808
Compare
Add the ProductCategory object type, create/update inputs (code is immutable so the update input does not expose it), Create/Update/Destroy mutations backed by the product_category services, and single + collection resolvers with pagination and search. Register the product_categories permissions (view/create/update/delete) and expose product_categories in the activity log resource union.
Add the Product object type with its item type enum, create/update inputs (code, item type and billable metric are immutable so the update input does not expose them), Create/Update/Destroy mutations backed by the product services, and single + collection resolvers with pagination, search and product_category/item type filters. Expose products and products_count on the ProductCategory type, register the products permissions and add products to the activity log resource union.
Add the ProductFilter and ProductFilterValue object types, the value input (billable metric filter + value pair), create/update inputs (code is immutable so the update input does not expose it; values are replaced wholesale when provided), Create/Update/Destroy mutations backed by the filter services, and single + collection resolvers with pagination, search and a product scope. Filters reuse the products permission family, are exposed on the Product type, and join the activity log resource union.
## Context The product_category detail page has a Plans tab listing the plans that include a given product_category. The plans link to product_categories through plan_product_categories. ## Description Add an optional product_category_id filter to PlansQuery and the plans resolver so the collection can be scoped to a single product_category, reusing the existing pagination, ordering and search. Filtering is done through a plan_product_categories subquery to avoid join duplicates.
## Context The product_category type exposed the full products collection inline, which duplicates the paginated products(productCategoryId:) resolver and is a payload and N+1 liability. The product_category detail page lists items through that resolver. ## Description Remove the products field from the ProductCategory GraphQL type and keep products_count, which is all the catalog list needs.
The product type exposed its filters collection inline, duplicating the paginated productFilters(productId:) resolver used by the product detail page. Remove the filters field from the Product GraphQL type. The detail page lists filters through the dedicated resolver.
The products resolver accepted a list of item types while the REST endpoint only ever filters by one. With just two item types, filtering by both is equivalent to no filter, so a single value is enough. Replace the product_types list filter with a single product_type on ProductsQuery and the resolver, so the GraphQL and REST surfaces are consistent.
## Context A product can have thousands of filters, each serialized with its values and their billable metric filters. Resolving those per row causes an N+1 on every paginated page. ## Description Eager load values and their billable_metric_filter in ProductFiltersQuery so both the GraphQL and REST list endpoints avoid the N+1 when rendering filter values.
## Context GraphQL product mutations remapped model association errors to the id input fields (billable_metric_id, product_category_id) through a SurfaceErrorFields concern. The FE owns its error-display mapping, so the neutral relation name is enough on GraphQL — while REST keeps translating to the _code params its callers actually send, where the contract is public. ## Description Drop the SurfaceErrorFields concern: GraphQL validation errors now carry the relation name emitted by the model. The REST boundary keeps its billable_metric_code and product_category_code mapping.
## Context The values argument of UpdateProductFilter is nullable, and graphql-ruby passes an explicitly-null argument through to the resolver as nil with the key present. ## Description Mapping nil to input hashes raised a NoMethodError, turning a malformed payload into an internal error. A null values list now reaches the validation layer and fails with value_is_mandatory like an empty one.
rsempe
force-pushed
the
products-and-plans-catalog-graphql
branch
from
August 10, 2026 09:35
4823808 to
9d752b8
Compare
## Context The input description still said "ProductCategory item filter value", a leftover from the naming era before items became products. ## Description Rename it to "Product filter value input arguments" and refresh the schema dumps accordingly.
rsempe
added a commit
that referenced
this pull request
Aug 10, 2026
## Context Hardening on top of the catalog services (#6098) and GraphQL surface (#6099): the immutability rules, the review fixes and the list-performance work. ## Description - Gate structural edits on attachment: a product, category or filter priced by a plan or subscription cannot change identity or be deleted (`attached_to_plan_or_subscription`); filter values freeze once a subscription bills through the filter (`attached_to_subscriptions`). - Creating a filter on a fixed product fails with `not_allowed_for_product_type`. - Reject duplicate filter value sets per product (`value_already_exist` on `values`) — two identical slices would be unresolvable for the billing engine; partial overlaps stay legal. - Batch `filters_count`, `products_count` and `attached_to_plan_or_subscription` through dataloader sources — one grouped query per page instead of per row.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
GraphQL CRUD for the three catalog entities, calling the services from #6098 so REST (later in the stack) shares the same behaviour.
Description
ProductCategory,ProductandProductFilter, with permissions (products:*,product_categories:*,product_filters:*).product_type.filtersfrom the product type andproductsfrom the category type.billableMetric,productCategory); REST (later in the stack) translates to the_codeparams it exposes.values: nullonupdateProductFilterfails withvalue_is_mandatoryinstead of crashing.Note for review:
productsCount/filtersCountresolve naively per row here; #5690 batches them through dataloader sources. Plans filter by a singleproductCategoryId(the plans page selector) while products filter byproductCategoryIds[]+withoutProductCategory(the catalog list multi-select) — intentional asymmetry.