- 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= |