Skip to content

feat(products): catalog guards, dedup and batching - #5690

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

feat(products): catalog guards, dedup and batching#5690
rsempe merged 8 commits into
mainfrom
products-and-plans-graphql

Conversation

@rsempe

@rsempe rsempe commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

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.

@rsempe rsempe changed the title feat(products): add product catalog GraphQL mutations and resolvers feat(products): add product catalog GraphQL Jun 10, 2026
@rsempe
rsempe force-pushed the products-and-plans-graphql branch from 7eee857 to a116c59 Compare June 10, 2026 18:35
@rsempe
rsempe force-pushed the products-and-plans-data-model branch from 2939678 to 7b5a2f2 Compare June 10, 2026 18:54
@rsempe rsempe changed the title feat(products): add product catalog GraphQL feat(products): Add product catalog GraphQL Jun 10, 2026
@rsempe
rsempe force-pushed the products-and-plans-graphql branch from a116c59 to 7936d23 Compare June 10, 2026 18:55
@rsempe
rsempe force-pushed the products-and-plans-data-model branch from 7b5a2f2 to 6cd4f01 Compare June 12, 2026 09:24
@rsempe
rsempe force-pushed the products-and-plans-graphql branch from 7936d23 to 5c6fe4b Compare June 12, 2026 09:24
@rsempe
rsempe force-pushed the products-and-plans-data-model branch from 6cd4f01 to 087acff Compare June 18, 2026 10:20
@rsempe
rsempe force-pushed the products-and-plans-graphql branch from 5c6fe4b to a68b644 Compare June 18, 2026 10:21
@rsempe
rsempe force-pushed the products-and-plans-data-model branch from 087acff to 75947d9 Compare June 18, 2026 11:46
@rsempe
rsempe force-pushed the products-and-plans-graphql branch 2 times, most recently from 7cfec6e to 13ba1e5 Compare June 18, 2026 12:00
@rsempe
rsempe force-pushed the products-and-plans-data-model branch from 75947d9 to 022eeba Compare June 18, 2026 12:00
@rsempe
rsempe force-pushed the products-and-plans-graphql branch from 13ba1e5 to c7fa0b7 Compare June 18, 2026 12:36
@rsempe
rsempe force-pushed the products-and-plans-data-model branch from 022eeba to 44cfa06 Compare June 18, 2026 12:36
@rsempe
rsempe force-pushed the products-and-plans-graphql branch from c7fa0b7 to df7fd8d Compare June 18, 2026 12:39
@rsempe
rsempe force-pushed the products-and-plans-data-model branch from 44cfa06 to 94fe18b Compare June 18, 2026 12:39
@rsempe
rsempe force-pushed the products-and-plans-graphql branch from 48b65b5 to f989762 Compare June 22, 2026 19:21
@rsempe
rsempe force-pushed the products-and-plans-data-model branch from 46f8c18 to 9fe3792 Compare June 22, 2026 20:45
@rsempe
rsempe force-pushed the products-and-plans-graphql branch from f989762 to da5d44d Compare June 22, 2026 20:53
@rsempe
rsempe force-pushed the products-and-plans-data-model branch from 9fe3792 to f9f703f Compare June 22, 2026 20:55
@rsempe
rsempe force-pushed the products-and-plans-graphql branch 2 times, most recently from 61904f9 to f5a7f2e Compare June 22, 2026 20:59
@rsempe
rsempe force-pushed the products-and-plans-data-model branch from f9f703f to 4993f07 Compare June 22, 2026 20:59
@rsempe
rsempe force-pushed the products-and-plans-graphql branch from f5a7f2e to fdf982f Compare June 24, 2026 12:22
@rsempe
rsempe force-pushed the products-and-plans-data-model branch from 4993f07 to 99efd84 Compare June 24, 2026 12:22
@rsempe
rsempe force-pushed the products-and-plans-graphql branch from fdf982f to 662b8b6 Compare June 30, 2026 10:43
@rsempe
rsempe force-pushed the products-and-plans-graphql branch from 91b41dc to c42f058 Compare July 16, 2026 14:59
@rsempe
rsempe force-pushed the products-and-plans-data-model branch 2 times, most recently from 64e5424 to 544f4aa Compare July 16, 2026 15:41
@rsempe
rsempe force-pushed the products-and-plans-graphql branch from c42f058 to 7e3b5d1 Compare July 16, 2026 15:41
@rsempe
rsempe force-pushed the products-and-plans-data-model branch from 544f4aa to c2252c9 Compare July 17, 2026 05:59
@rsempe
rsempe force-pushed the products-and-plans-graphql branch 2 times, most recently from 3b40e8a to 83e7c8f Compare July 17, 2026 06:44
@rsempe
rsempe force-pushed the products-and-plans-data-model branch from c2252c9 to 8f2422a Compare July 17, 2026 07:37
rsempe added 7 commits August 10, 2026 11:59
Once a catalog entity is part of a plan or subscription, its structural
fields must stop changing: the code, an item's product_category attachment, and a
filter's values. Cosmetic fields (name, description, invoice display name)
stay editable.

In the update services, assign code (product_categories, items, filters), product_category
attachment (items) and values (filters) only while the entity is not
attached to a plan or subscription, silently skipping them otherwise.
Expose those attributes on the update inputs and add an
attached_to_plan_or_subscription field on the GraphQL types so the UI knows
when to lock them.
## Context

Creating a filter on a fixed product returned invalid_product_type. The
product type sent is perfectly valid — it is the operation that is
incompatible with a fixed product, which has no metric to filter. The code
also sat outside the v2 error family, which reserves value_is_invalid for
unknown values and not_allowed_for_<field> for valid-but-conflicting ones.

## Description

Rename the code to not_allowed_for_product_type, matching the grammar of
not_allowed_for_billing_timing and friends.
## Context

values_changed? sorts [filter_id, value] pairs to compare the stored
values with the submitted ones. A payload mixing a key-only entry (nil
value) with a valued entry for the same key made the sort compare nil
with a string and raise, turning an invalid request into a 500 on
filters already billed through by a subscription — the one path where
the comparison runs before the values validation.

## Description

Compare values as strings on both sides. Key-only entries stay
distinguishable: an empty-string value is invalid, so the empty string
only ever represents nil.
## Context

Two filters with the exact same resolved value set price the same event
slice. The plan-side guard is per filter id, not per value set, so a
plan could carry two cards pricing the same slice at different rates —
an ambiguity the billing engine cannot resolve. Partial overlaps stay
legal: they are real use cases and resolve by match precedence.

## Description

Reject a filter whose resolved value set exactly matches another
filter's on the same product with value_already_exist on values, at
creation and when an update rewrites the values. The filter being
updated is excluded from the scan so resending its own values stays a
no-op. App-level check only — a product has a handful of filters; a
values digest column with a unique index is the path if a DB backstop
is ever needed.
## Context

The catalog list queries resolved filters_count, products_count and
attached_to_plan_or_subscription per row: one COUNT plus two EXISTS
queries for every product, filter or category on a page, so a 50-item
list issued ~150 queries when the FE selected those fields.

## Description

Batch them through two dataloader sources. CountByForeignKey turns the
per-row counts into a single grouped count. CatalogAttachment answers
attachment for a whole page with one grouped query per applied-card
side, keyed by product for products and their filters — a filter is
attached when its product is, so both share the same batch — and by
category through its products. Default scopes still apply, so
discarded rows stay excluded exactly as before.
## Context

The create services strip the code param but the update paths added
with the attachment gating assigned it raw, so a padded code could be
stored on rename and a padded-but-identical code tripped the
attachment guard.

## Description

Strip the code in the three update services, both when assigning and
when comparing against the current code for the structural-edit guard.
Comment thread app/services/product_filters/update_service.rb Outdated
The duplicate value-set and attachment checks are check-then-act: two
concurrent filter writes on the same product could both pass the
duplicate scan and commit identical value sets.

Hold the product row lock around the checks and the write in the
filter create and update services so concurrent writers serialize. A
values edit racing a subscription attach is left to the attach path,
which will take the same lock when it lands.
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