Skip to content

feat(products): add catalog GraphQL - #6099

Merged
rsempe merged 11 commits into
mainfrom
products-and-plans-catalog-graphql
Aug 10, 2026
Merged

feat(products): add catalog GraphQL#6099
rsempe merged 11 commits into
mainfrom
products-and-plans-catalog-graphql

Conversation

@rsempe

@rsempe rsempe commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Context

GraphQL CRUD for the three catalog entities, calling the services from #6098 so REST (later in the stack) shares the same behaviour.

Description

  • Types, mutations and resolvers for ProductCategory, Product and ProductFilter, with permissions (products:*, product_categories:*, product_filters:*).
  • Filter plans by product category, and products by a single product_type.
  • Trim the type graph to what the FE consumes: drop filters from the product type and products from the category type.
  • Eager load values in the product filters query to avoid N+1s.
  • GraphQL validation errors keep the relation name (billableMetric, productCategory); REST (later in the stack) translates to the _code params it exposes.
  • An explicit values: null on updateProductFilter fails with value_is_mandatory instead of crashing.

Note for review: productsCount/filtersCount resolve naively per row here; #5690 batches them through dataloader sources. Plans filter by a single productCategoryId (the plans page selector) while products filter by productCategoryIds[] + withoutProductCategory (the catalog list multi-select) — intentional asymmetry.

Comment thread app/graphql/types/product_filter_values/input.rb Outdated
@rsempe
rsempe force-pushed the products-and-plans-catalog-graphql branch from 7e1d06a to bd7d671 Compare August 10, 2026 09:10
@rsempe
rsempe force-pushed the products-and-plans-catalog-services branch from f69a6b5 to 0fe1e02 Compare August 10, 2026 09:10
@rsempe
rsempe force-pushed the products-and-plans-catalog-graphql branch from bd7d671 to 4823808 Compare August 10, 2026 09:23
Base automatically changed from products-and-plans-catalog-services to main August 10, 2026 09:32
rsempe added 10 commits August 10, 2026 11:33
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
rsempe force-pushed the products-and-plans-catalog-graphql branch from 4823808 to 9d752b8 Compare August 10, 2026 09:35
## 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
rsempe merged commit 2b515ca into main Aug 10, 2026
12 of 13 checks passed
@rsempe
rsempe deleted the products-and-plans-catalog-graphql branch August 10, 2026 09:56
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants