Org Web Adapter

hungryroot/brand/exp242-cookbook-reuse-analysis.org

ID
7030902b-189c-4275-a3fc-437a03b2fadc

Favorites Tab — Cookbook / Bookmark Reuse Analysis

TL;DR

Companion to exp242-favorites-tab-backend-review.org. Question: can

the existing =cookbooks= app or a "bookmark" abstraction back the

mixed-type Favorites tab instead of a new union endpoint?

- *Bookmark / SavedItem abstraction:* Does not exist. Only customer-level

saves today are =CustomerFavoritePairing= and =CustomerFavoriteProduct=.

- *Cookbook reuse (as storage):* Wrong fit. Cookbook is editorial,

non-customer-scoped, and items must hang off a =CookbookSection= with a

curated =position=. No FK from cookbook items to customer favorites.

- *Cookbook reuse (as pattern):* Strong fit. =CookbookItem= already

models exactly the mixed-type shape the new endpoint needs —

discriminator enum + nullable typed FKs + DB-level check constraints.

Reuse the *pattern*, not the table.

- *Recommendation:* Stay with the v3 favorites endpoint proposed in the

prior doc. Borrow =CookbookItemType=-style discrimination for the

serializer/response shape so existing clients have a familiar

vocabulary.

Existing Mixed-Item Pattern in Cookbook

=cookbooks/models.py= already solves "one row, either product or

pairing" cleanly. Worth mirroring in the favorites response.

CookbookItemType (the discriminator)

#+begin_src python

class CookbookItemType(models.TextChoices):

PAIRING = "pairing", "Pairing"

PRODUCT = "product", "Product"

#+end_src

File: =cookbooks/models.py= L17-19

CookbookItem (the mixed row)

#+begin_src python

class CookbookItem(BaseModelV1):

section = models.ForeignKey(CookbookSection, on_delete=CASCADE, related_name="items")

item_type = models.CharField(max_length=10, choices=CookbookItemType)

pairing = models.ForeignKey("app.Pairing", null=True, blank=True, on_delete=PROTECT)

product = models.ForeignKey("app.Product", null=True, blank=True, on_delete=PROTECT)

position = models.PositiveIntegerField()

class Meta:

constraints = [

models.CheckConstraint( # exactly one of (pairing, product)

condition=Q(pairing__isnull=False, product__isnull=True)

| Q(pairing__isnull=True, product__isnull=False),

name="cookbookitem_one_target_ck",

),

models.CheckConstraint( # item_type must match the populated FK

condition=Q(item_type=CookbookItemType.PAIRING, pairing__isnull=False)

| Q(item_type=CookbookItemType.PRODUCT, product__isnull=False),

name="cookbookitem_type_target_ck",

),

...

]

#+end_src

File: =cookbooks/models.py= L170-194

This is the same discriminator the prior doc proposes for the response

shape (={item_type, id, favorited_at}=). The naming convention

(=CookbookItemType=) is the natural precedent for a =FavoriteItemType=

choices enum on the new endpoint.

Why Cookbook Cannot Store Favorites

Cookbook is editorial, not personal

=Cookbook= has =is_featured=, =is_active=, =sort_order=, =author= — fields

for curated content. No =customer= FK. Authoring a per-customer

"Favorites" cookbook would mean either:

- Adding =customer= FK to =Cookbook= (changes the model's semantics

globally; impacts admin, listings, CMS workflows)

- Creating one =Cookbook= row per customer (millions of rows in a table

built for ~tens to hundreds of editorial entries)

Both are non-starters.

CookbookItem requires a CookbookSection parent

=CookbookItem.section= is a non-null FK. Sections have

=UniqueConstraint(fields=["cookbook", "position"])= and items have

=UniqueConstraint(fields=["section", "pairing"|"product"])=. The model

assumes manual curation order.

Favorites need:

- Order by =favorited_at= (a timestamp), not by curator-assigned

=position=

- No section concept at all

Forcing favorites into a "section" is a square-peg fit and would require

synthetic single-section-per-customer rows. No upside.

No hydration in cookbook serializers

=CookbookItemSerializer= exposes =item_type=, =pairing_id=, =product_id=

only — no card hydration. Clients hydrate via the same v3 bulk endpoints

the favorites doc already proposes (=v3/products?ids=...=,

=v3/pairings?ids=...=). Cookbook offers no shortcut here.

Why a New "Bookmark" Model Is Also Overbuild

Tempting alternative: introduce a generic =Bookmark(customer, content_type,

object_id)= using =ContentType= to unify favorites. Cost vs benefit:

- Migration of =CustomerFavoritePairing= and =CustomerFavoriteProduct=

data into a new table — large tables, write-heavy paths (POST/DELETE

favorite endpoints), no rollback story

- Loss of FK integrity per item type (=GenericForeignKey= isn't enforced

at DB level)

- v2 favorite endpoints either break or need a compat shim

- No new capability — the union query the proposed endpoint runs is just

as cheap against two purpose-built tables as against one polymorphic

one, and reads better

Skip unless a third favoritable type appears (saved meal plans, saved

brands, etc.). YAGNI.

What to Borrow from Cookbook

Reuse the *shape*, not the storage.

1. Discriminator enum naming

Define =FavoriteItemType= in =app/const.py= (or new module) modeled on

=CookbookItemType=:

#+begin_src python

class FavoriteItemType(models.TextChoices):

PAIRING = "pairing", "Pairing"

PRODUCT = "product", "Product"

#+end_src

Clients consuming both =/cookbooks/...= and =/favorites/= will see the

same =item_type= vocabulary.

2. Serializer response shape

Match the cookbook item field order: =item_type=, then the typed id

(=pairing_id= / =product_id= rather than a single =id= field), plus

=favorited_at=:

#+begin_src json

{

"count": 42,

"next": "...",

"results": [

{"item_type": "pairing", "pairing_id": 463891, "product_id": null, "favorited_at": "..."},

{"item_type": "product", "pairing_id": null, "product_id": 1210, "favorited_at": "..."}

]

}

#+end_src

Trade-off: prior doc suggested a single =id= field. Typed-id matches

=CookbookItemSerializer= exactly and removes any client ambiguity at the

cost of two nullable fields per row. Pick one and document.

3. Service-layer composition

=cookbooks/= already hydrates mixed lists via separate queries per item

type and assembles in Python. Same pattern fits the favorites service:

#+begin_src python

class CustomerFavoritesService:

def list_favorites(self, customer, occasion=None, ordering="-favorited_at", limit=20, offset=0):

product_qs = CustomerFavoriteProduct.objects.filter(customer=customer)

pairing_qs = CustomerFavoritePairing.objects.filter(customer=customer)

if occasion:

product_qs = self._apply_product_occasion(product_qs, occasion)

pairing_qs = self._apply_pairing_occasion(pairing_qs, occasion)

# union via values() with shared columns, sort, paginate

...

#+end_src

Keep the view thin per =service-first architecture= rule.

Union vs Two-Query-Merge

Two viable implementations of the unified feed:

1. *DB-level UNION* over =values()= projections of both through tables

with a synthetic =item_type= column. Lets MySQL do the sort + slice.

Best for "all" with =-favorited_at=.

2. *App-level merge* — fetch both querysets sorted by =favorited_at=,

merge-sort in Python, slice for the page. Simpler to read; costs an

extra fetch when occasion filtering is asymmetric (e.g., Lunch+Dinner

for products is heuristic).

Recommend UNION when both sides share the same occasion semantics

(=all=, =breakfast=), app-level merge when product/pairing occasion

filters diverge. Service can switch internally.

Recommendation

1. *Do not* repurpose =cookbooks.Cookbook= or =CookbookItem= as storage.

2. *Do not* introduce a generic =Bookmark= polymorphic model.

3. *Keep* =CustomerFavoritePairing= and =CustomerFavoriteProduct= as

storage — they already have =create_date= (migration 0697).

4. *Build* the new =GET /api/v3/customers/{customer_id}/favorites/=

endpoint as proposed in the prior doc.

5. *Borrow* from cookbook:

- =FavoriteItemType= choices enum mirroring =CookbookItemType=

- =item_type + pairing_id + product_id= response shape

- Service-layer hydration pattern

Open Questions (delta from prior doc)

- [ ] Response shape: single =id= field vs typed =pairing_id=/=product_id=

(typed matches cookbook precedent)

- [ ] Naming: =FavoriteItemType= in =app/const.py= vs co-locating with

the new favorites view/service

- [ ] UNION vs app-level merge in the service — defer until occasion

filter logic for products is finalized

Key File References

| Topic | Files |

|-------+-------|

| Cookbook mixed-item pattern | =cookbooks/models.py= L17-19, L170-194 |

| Cookbook serializer (no hydration) | =cookbooks/serializers.py= =CookbookItemSerializer= |

| Discovery-home collection item types | =discovery_home/const.py= =CollectionItemType= |

| Current favorite models | =app/models/customer_favorite_product.py=, =app/models/customer_favorite_pairing.py= |

| Through-table timestamps migration | =app/migrations/0697_customerfavoriteproduct_customerfavoritepairing_and_more.py= |