- ID
- 5c0f7bd3-053e-47ed-a707-507d36f1666a
Exp 242 - Favorites + User Cookbooks
Exec (TLDR) summary
Expose a unified Favorites surface as a thin *pass-through proxy* over the
existing =CustomerFavoritePairing= / =CustomerFavoriteProduct= tables. Those
tables remain the source of truth. The new
=/api/v3/customers/{id}/cookbooks/favorites/= endpoint synthesizes a
Cookbook-shaped *read* façade from legacy rows at request time.
*For the MVP, writes (favoriting / unfavoriting) continue to use the existing
V2 endpoints* — no V3 POST/DELETE is shipped in this release. *No* schema
change to =Cookbook=. *No* data migration, backfill, dual-write, advisory
locks, or cutover DC.
Occasion bucket mappings (see Figma for "Breakfast", "Lunch + Dinner"
pill buttons) are server-owned via a new DynamicConfig public setting.
The Favorites response envelope mirrors =CookbookSerializer= field-for-field
where applicable (with null/empty defaults) and diverges only on pagination:
items live in a single synthetic =sections[0].items[]= section that is
paginated, rather than the static =sections[*]= of a curated cookbook.
When a user shares a cookbook, create "shell" Cookbook instance with a unique
slug and a new =CookbookType= of =favorites= which builds on
[[https://hungryroot.atlassian.net/wiki/spaces/BA/pages/1430945793][Exp 232 - Cookbooks]] and is exposed via the existing cookbook API:
=/api/v3/cookbooks/<slug>/=.
API client summary
What client engineers need to integrate Favorites. The entire v3 surface is
*read-only*; favoriting / unfavoriting still goes through the existing v2
endpoints. v2 writes hit the same source-of-truth tables, so they show up in
the next v3 read with no sync step.
New v3 read endpoints
- =GET /api/v3/customers/{customer_id}/cookbooks/favorites/= :: the caller's
favorites as a Cookbook-shaped envelope (mixed pairings + products).
- Query params:
- =filter= (bucket slug, e.g. =lunch_dinner=)
- =ordering= (default =-create_date=, or =create_date=; any other value → *400*)
- =limit= / =offset= (DRF pagination)
- Items live at =sections[0].items[]=.
- =cookbook_type= and =kind= are both ="favorites"=.
- Each item =id= is an opaque composite (=pairing-{id}= / =product-{id}=).
- =GET /api/v3/cookbooks/{id|slug}/= :: public, shareable cookbook by slug.
- Same response shape.
- Used for shared / user-created favorites cookbooks
(=cookbook_type="favorites"=).
Occasion filters
- Fetch the bucket → tag mapping from the public DynamicConfig
=api.favorites.occasion_filters= on app launch, cache for the session, then
pass the bucket display name as =?filter=Lunch & Dinner=.
- Bucket display names ("Breakfast", "Lunch & Dinner", "Snacks & Meals") are
the DC keys and the =filter= param values; no client-side mapping needed.
Writes — use existing v2 endpoints (unchanged)
- =POST /api/v2/customers/{customer_id}/favorite_pairings/= ·
=DELETE /api/v2/customers/{customer_id}/favorite_pairings/{pairing_id}/=
- =POST /api/v2/customers/{customer_id}/favorite_products/= ·
=DELETE /api/v2/customers/{customer_id}/favorite_products/{product_id}/=
*Rendering note:* if =cookbook_type == "favorites"=, iterate
=sections[0].items[]=; otherwise use the curated-cookbook =sections[*].items[]=.
Every per-item field matches =CookbookItemSerializer=.
Overview
- POD :: Brand
- Product Requirements / Brief :: [[https://docs.google.com/document/d/1jHbKNiupiZp2Ece2PhPienjJ4kC8EUvkJorzRpa1j6I/edit?tab=t.kupfnodp89p8][Google Doc]]
- Design Document :: TODO
- Figma :: [[https://www.figma.com/design/ewERdxFs62fj2MiXjiS1b2/Exp-xxx--New-Bottom-Navigation?node-id=533-43073&p=f&m=devPrototype][New Bottom Navigation]]
- Epic :: [[https://hungryroot.atlassian.net/browse/EP-650][EP-650]]
- Experiment :: Exp 242
- Slack Channel :: [[https://hungryroot.slack.com/archives/C0B4QA7Q05A][#exp-242]]
- Target Complete Date (Backend) :: TODO
- Target Launch Date (Feature) :: TODO
- WEB spec :: TODO
People
| Role | Review Approved (add initials) |
|-------------------------------+--------------------------------|
| Stakeholder Lead - TODO @ | |
| Product Lead - TODO @ | |
| Technical Team Lead - TODO @ | |
| Technical Primary - TODO @ | |
| Technical Secondary - TODO @ | |
| QA Lead - TODO @ | |
| Data Lead - TODO @ | |
| BFL Lead - TODO @ | |
| Additional Technical - TODO @ | |
Timeline and milestones
| Item | Date | Description | Key Stakeholder Sign-off |
|--------------------------------+------+-------------+---------------------------------------|
| Spec review complete | | | Primary Dev / Dev Lead / Project Lead |
| Code complete | | | ... |
| QA starts | | | ... |
| P1 features staging roll out | | | ... |
| P1 features production rollout | | | ... |
Background
The mobile apps and web site want a unified *Favorites* tab that mixes
favorited recipes (pairings) and products in a single list, filterable by
occasion buckets (All, Breakfast, Lunch+Dinner, Snacks+More) and sortable
by recency. Today favorites are split across two flat tables
(=CustomerFavoritePairing=, =CustomerFavoriteProduct=) with no shared ordering
and no pagination strategy. At the same time, we want to grow engagement with
curated Cookbooks ([[https://hungryroot.atlassian.net/wiki/spaces/BA/pages/1430945793][Exp 232]]) and enable *user-created Cookbooks* in the same
surface.
The Favorites surface and Cookbooks surface look superficially similar —
heterogeneous lists of pairings + products — but they have very different data
shapes. Favorites are one-row-per-favorited-thing with no ordering or notes;
curated Cookbooks are sectioned, ordered, staff-authored. Conflating them
forces either a large data migration (move all favorites into =CookbookItem=
rows, dual-write during cutover, deprecate the legacy tables) or a thin proxy
that gives the client the Cookbook-shaped response it wants without moving any
data. This spec takes the proxy route.
High-Level Solution
- *V3 read endpoint proxies the legacy favorite tables.* The new
=/api/v3/customers/{id}/cookbooks/favorites/= endpoint synthesizes a
Cookbook envelope from =customer.favorite_pairings= +
=customer.favorite_products= at request time. No =Cookbook= rows are created
for Favorites.
- *Writes stay on V2 for the MVP.* Clients continue to favorite/unfavorite via
the existing =/api/v2/.../favorite_pairings/= and =/favorite_products/=
endpoints. Because the V3 read proxies the same tables, V2 writes are
immediately reflected in the next V3 fetch. No dual-write or sync logic
needed.
- *Truth stays in* =CustomerFavoritePairing= / =CustomerFavoriteProduct=. The
legacy =/api/v2/customers/{id}/favorite_pairings/= and =/favorite_products/=
endpoints stay live. =get_customer_favorite_pairings= (used for
=is_favorited= enrichment in pairing list responses) keeps working unchanged.
- *Occasion filtering is server-driven via a public DynamicConfig.* The
favorites endpoint accepts a =filter= query parameter whose value is a bucket
display name (a key of the DC dict =api.favorites.occasion_filters=, e.g.
="Lunch & Dinner"=). Each bucket maps to an =OccasionBucket= with separate
=pairing_tags= and =product_tags= lists of =Tag= IDs, so pairings and
products can be filtered by different tag sets.
- *Response mirrors* =CookbookSerializer=. Every top-level field on the
curated-Cookbook detail response is present on the Favorites envelope — with
null/empty defaults where Favorites has no analog (e.g. =author=,
=collections=, =description=, =hero_image=). The only intentional divergence
is pagination: items live in a single synthetic =sections[0].items[]= section
that is paginated, rather than the static =sections[*]= of a curated
cookbook. Clients branch on =cookbook_type == "favorites"= for the items
iteration; every other render path is shared.
- *User-created Cookbooks* will require adding one new enum value of
=favorites= to allow distinguishing user-created cookbooks from
marketing-curated. Existing favorites tables are still used to synthesize
the items in the user-created cookbook. The =slug= field will be used to
create a unique share URL for each cookbook. Cookbooks will be public by
default, with no way to disable sharing, except marking them as
=is_active=False=.
Experiment
*Experiment cleanup if it wins:* Nothing to roll forward on the data side —
favorites never moved. Cleanup is limited to (a) removing the legacy
=/api/v2/.../favorite_pairings/= and =/favorite_products/= REST views once all
supported app versions have cut over to the v3 favorites endpoint, and (b)
keeping the user-cookbook code. The =api.favorites.occasion_filters= DC and
lookup endpoint stay live indefinitely (they own the bucket mapping).
*Experiment cleanup if it loses:* Delete the new view files, the
=FavoritesService=, the bucket-mapping DC, the lookup endpoint, and any
Cookbooks of CookbookType =favorites=. No data to roll back. The
=CookbookType.FAVORITES= enum value can stay as a harmless additive constant.
Dynamic Configs
- =api.favorites.occasion_filters= :: JSON dict mapping occasion bucket display
name (e.g. ="Lunch & Dinner"=) to an =OccasionBucket= with separate
=pairing_tags= and =product_tags= lists of =Tag= IDs. Read via a cached
getter on =DynamicConfig= (=@cache_memoize(300)=).
Launch Plan
TBD with Product. See *Cutover Plan* below for the rollout sequence.
Implementation
All changes land in =cookbooks/= (Favorites read endpoint + occasion-filter
lookup endpoint + =CookbookType.FAVORITES= enum value) and =app/= (DC
registration, getter). The legacy favorite REST views in =app/rest/= remain in
place; nothing is deprecated until clients have cut over.
Apps
- =cookbooks= :: EXTEND (new endpoints + service; add =FAVORITES = "favorites"=
to =CookbookType=; no other model changes for Favorites)
- =app= :: register the new DC (=app/constants/dynamic_config.py=), add the
typed getter (=app/models/dynamic_config.py=), optional admin validation
rule (=app/admin/dynamic_config.py=). Legacy =CustomerFavoritePairing= /
=CustomerFavoriteProduct= remain authoritative — no model changes there.
Models & Fields
Favorites
*No schema changes.* No new tables, no new fields. Adds =FAVORITES =
"favorites"= to the existing =CookbookType= choices in =cookbooks/models.py=
as a serialization marker on the synthesized envelope. No migration needed —
no Cookbook rows are written with this value.
The proxy reads through-rows directly from =CustomerFavoritePairing= /
=CustomerFavoriteProduct= so the sort by =create_date= reflects favoriting
time (not when the underlying Pairing / Product was created):
#+begin_src python
# Already defined in app/models/customer_favorite_pairing.py
# and app/models/customer_favorite_product.py
CustomerFavoritePairing.objects.filter(customer=customer)
CustomerFavoriteProduct.objects.filter(customer=customer)
#+end_src
User-Created Cookbooks
If user-created Cookbooks are in scope for this release, add two fields to
=Cookbook=:
#+begin_src python
class CookbookType(models.TextChoices):
THEMATIC = "thematic", "Thematic" # current Exp 232 cookbooks
PERSONALITY = "personality", "Personality" # current Exp 232 cookbooks
FAVORITES = "favorites", "Favorites" # Added as part of Exp 242
#+end_src
Notes:
- Customer mapping to a Cookbook is done via the =CookbookAuthor= table which
links to the =User= table. This m
Occasion-filter mapping
No table. Server-side bucket-slug → tag mapping lives in the
=api.favorites.occasion_filters= DC. The =slug= → =Tag.id= map for the hybrid
product filter lives in =cookbooks/const.py=. See *Occasion Filters* below.
APIs
Retrieve Personal Favorites Cookbook
=GET /api/v3/customers/{customer_id}/cookbooks/favorites/=
Returns the caller's favorites as a synthesized Cookbook envelope with mixed
pairing+product items. The envelope mirrors =CookbookSerializer=
field-for-field for top-level fields (with null/empty defaults where Favorites
has no analog) and diverges only on the items wrap — items live in a single
synthetic =sections[0].items[]= section that is paginated, rather than the
static =sections[*]= of a curated cookbook. Reads directly from the legacy
favorite tables; no =Cookbook= rows are created.
*Query Parameters:*
- =filter= (string, optional) :: A single occasion bucket display name (a key
of =api.favorites.occasion_filters=, e.g. =Lunch & Dinner=). The server
resolves it to an =OccasionBucket= and filters pairings by =pairing_tags=
and products by =product_tags= independently. Unknown value → =400=. Omit
for the full favorites set.
- =ordering= (string, default =-create_date=) :: One of =-create_date= (recent
first), =create_date= (oldest first). Any other value raises a 400 (the
service raises =ValueError=, which the view translates). Custom
position/reordering is not supported in Phase 1; the underlying tables don't
store a position.
- =limit=, =offset= :: Offset-based pagination (DRF =LimitOffsetPagination=
shape). The response carries =count= plus =next= / =previous= absolute page
URLs (=null= at the ends). Internally the service returns =next_offset= /
=previous_offset= integers; the view renders them into URLs from the request
(it owns the absolute URI + route). See *Services* below.
*Response:*
#+begin_src json
{
"id": null,
"slug": "favorites-12345",
"name": "My Favorites",
"description": null,
"hero_image": null,
"is_featured": false,
"is_active": true,
"sort_order": 0,
"cookbook_type": "favorites",
"kind": "favorites",
"owner_id": 12345,
"author": null,
"dietary_tag_ids": [],
"collections": [],
"pairing_count": 30,
"product_count": 12,
"count": 42,
"next": "/api/v3/customers/12345/cookbooks/favorites/?limit=20&offset=20",
"previous": null,
"sections": [
{
"id": null,
"name": null,
"position": 0,
"items": [
{
"id": "pairing-125451",
"item_type": "pairing",
"position": 0,
"pairing_id": 125451,
"product_id": null
},
{
"id": "product-826",
"item_type": "product",
"position": 1,
"pairing_id": null,
"product_id": 826
}
]
}
]
}
#+end_src
*Count semantics.* =count=, =pairing_count=, and =product_count= are all
post-filter, pre-pagination — they reflect the =filter= occasion bucket but
not pagination. =count= always equals =pairing_count= + =product_count=. (This
diverges from curated =CookbookSerializer=, whose counts describe the whole
cookbook; Favorites counts describe the filtered set the client is viewing.)
*Item id encoding:* Legacy rows live in two tables with independent PKs, so
the synthesized =id= is a composite string ="pairing-{id}"= /
="product-{id}"=. Mobile clients treat the field as opaque so this shape
difference vs. curated cookbooks is invisible.
*Position:* Reflects the item's global rank in the sorted result set
(offset-aware). On page 2 of a paginated response, the first item's =position=
equals the offset, not 0.
Retrieve Shared Favorites Cookbook
=GET /api/v3/cookbooks/{id|slug}/=
Returns the same shape as the Favorites URL above, but public and allows
"sharing" a cookbook using a unique slug field. Recommend an auto-slug
generation approach like Base62-encoded random bytes, which results in slugs
like: =aK7mX2nQ= and used by Spotify and YouTube. Statistically unlikely to
collide over a 13 million generations.
Create / Remove Favorites — MVP uses V2 endpoints
For the MVP, the V3 surface is *read-only*. Clients continue to favorite and
unfavorite items via the existing V2 endpoints. Because the V3 read endpoint
proxies the same source-of-truth tables, V2 writes are immediately reflected
in the V3 favorites cookbook on the next fetch — no dual-write or sync code
needed.
*Use these for MVP:*
- =POST /api/v2/customers/{customer_id}/favorite_pairings/= — favorite a
pairing
- =DELETE /api/v2/customers/{customer_id}/favorite_pairings/{pairing_id}/= —
unfavorite a pairing
- =POST /api/v2/customers/{customer_id}/favorite_products/= — favorite a
product
- =DELETE /api/v2/customers/{customer_id}/favorite_products/{product_id}/= —
unfavorite a product
V3 =POST=/=DELETE= equivalents may be added in a follow-up once we want to
consolidate the favorites surface fully under V3. They are out of scope for
this experiment.
Legacy v2 endpoints (unchanged)
The endpoints listed above stay live, backed by the same tables. Apps may
continue to use them indefinitely; the v3 read surface is additive.
Removal of the legacy endpoints is out of scope for this experiment; revisit
once all supported app versions are on v3.
User-Created Cookbooks
Neither users nor clients will directly create Cookbooks for the unique share ID,
rather when a user chooses to share, we'll generate a "shell" cookbook on the
fly tied to the user (via =CookbookAuthor= → =User= → =Customer=). No APIs
for creating cookbooks need to be exposed to clients.
Occasion Filters
Buckets (Breakfast / Lunch+Dinner / Snacks+More) map to lists of =Tag.id=.
The Lunch+Dinner mapping is *server-owned*, stored in a single DynamicConfig
and read by clients via a public DynamicConfig.
*Why server-owned (vs. per-platform UI constants):*
- Single source of truth across iOS, Android, and web — no drift between
client copies.
- PM can reshuffle bucket contents (or absorb a newly added =Tag=) without a
client release; one admin edit covers all clients.
- Pattern is idiomatic in the codebase: =marketing.utm_source.redirect=,
=checkout.dow_priority=, and =cyo2.recommendation_limit= all store
dict-shaped config validated by Pydantic and cached via =@cache_memoize=.
- Bucket *labels* ("Lunch + Dinner", "Snacks & More") remain client-side copy
/ i18n concern. Only the slug/tag_id mapping list is server-owned.
*DynamicConfig:* =api.favorites.occasion_filters=
- Shape: =dict[str, OccasionBucket]= where each bucket has =pairing_tags:
list[int]= and =product_tags: list[int]=
- Default value:
#+begin_src json
{
"Breakfast": {"pairing_tags": [47], "product_tags": [27,28,29,30,31,32,33,34]},
"Lunch & Dinner":{"pairing_tags": [48, 173], "product_tags": []},
"Snacks & Meals":{"pairing_tags": [], "product_tags": [35,36,37,38,39,40,41,42,43,44,45,46]}
}
#+end_src
=product_tags= for =Lunch & Dinner= pending H&E / RTE tag IDs from prod.
- Validator: =PydanticJSONValidator(OccasionFiltersModel)=.
- Getter: =get_occasion_filters()= in =cookbooks/services.py=. Returns ={}=
and logs on parse failure (clients see an empty bucket, not a 500).
*Flow:*
1. Client fetches the DC dict on app launch; keys are the display names shown
in the UI. Result cached on the client for the session.
2. User taps a bucket tab; client passes the display name directly:
#+begin_src shell
GET /api/v3/customers/12345/cookbooks/favorites/?filter=Lunch%20%26%20Dinner
#+end_src
3. Server looks up the =OccasionBucket=, filters pairings by =pairing_tags=
and products by =product_tags= independently.
Services
All business logic lives in =cookbooks/services.py= (alongside
=CookbookService= from Exp 232). For the MVP, only the read path is
implemented; =favorite()= / =unfavorite()= methods are deferred until a future
V3 write surface is in scope.
#+begin_src python
# cookbooks/services.py
from app.models import Customer, CustomerFavoritePairing, CustomerFavoriteProduct
from cookbooks import models
from cookbooks.datatypes import OccasionBucket
class FavoritesService:
def list_favorites(
self,
customer: Customer,
occasion_bucket: OccasionBucket | None = None,
ordering: str = "-create_date",
limit: int | None = None,
offset: int | None = None,
) -> FavoritesEnvelope:
if ordering not in ("create_date", "-create_date"):
raise ValueError(...)
pairing_tags = occasion_bucket.pairing_tags if occasion_bucket else None
product_tags = occasion_bucket.product_tags if occasion_bucket else None
pairing_rows = self._collect_pairing_rows(customer, pairing_tags)
product_rows = self._collect_product_rows(customer, product_tags)
# ... merge, sort by create_date, paginate, serialize into the Cookbook-shaped envelope ...
@staticmethod
def _collect_pairing_rows(customer, pairing_tags: list[int] | None):
if pairing_tags is not None and not pairing_tags:
return []
queryset = CustomerFavoritePairing.objects.filter(customer=customer)
if pairing_tags is not None:
queryset = queryset.filter(pairing__tags__id__in=pairing_tags).distinct()
return [...] # (pairing_id, create_date) rows
@staticmethod
def _collect_product_rows(customer, product_tags: list[int] | None):
if product_tags is not None and not product_tags:
return []
queryset = CustomerFavoriteProduct.objects.filter(customer=customer)
if product_tags is not None:
queryset = queryset.filter(product__tags__id__in=product_tags).distinct()
return [...] # (product_id, create_date) rows
#+end_src
Key properties:
- *Read-only for MVP.* Writes route through the existing V2 favorite REST
views; no new write code is added.
- *Cookbook-shaped envelope.* The returned dict carries every top-level field
=CookbookSerializer= exposes (=id=, =slug=, =name=, =description=,
=hero_image=, =is_featured=, =is_active=, =sort_order=, =cookbook_type=,
=author=, =dietary_tag_ids=, =collections=, =pairing_count=,
=product_count=) with null/empty defaults where Favorites has no analog.
=cookbook_type= uses the new =CookbookType.FAVORITES = "favorites"= enum
value. =kind: "favorites"= and =owner_id= are additive Favorites-specific
discriminators.
- *Items shape matches* =CookbookItemSerializer=. Each item carries =id=,
=item_type=, =position=, =pairing_id=, =product_id= — same field names as a
curated CookbookItem. Items live in a single synthetic =sections[0].items[]=
section that is paginated, rather than the static =sections[*]= of a curated
cookbook.
- *Counts.* =count=, =pairing_count=, and =product_count= are all post-filter,
pre-pagination, taken from the collected row lists (=len(pairing_rows)= /
=len(product_rows)=) — no extra =count()= queries. =count= always equals
=pairing_count= + =product_count=.
- *Pagination offsets.* The service owns the pagination math, not URL
building: it returns =next_offset= / =previous_offset= integers (=None= at
the ends) computed from =total=, =limit=, =offset=. The view renders these
into absolute =next= / =previous= URLs, since only the request knows the
absolute URI + route. Keeps the service HTTP-agnostic and unit-testable.
- *Independent tag filters.* Pairings are filtered by =pairing_tags= and
products by =product_tags= from the resolved =OccasionBucket=. An empty list
on either side returns nothing for that type; =None= (no filter) returns all.
- *Through-row reads.* Service queries =CustomerFavoritePairing= /
=CustomerFavoriteProduct= directly with =.values("...", "create_date")= so
the sort uses favoriting time, not the underlying Pairing's create date.
- *Ordering validation.* The service raises =ValueError= if =ordering= is
anything other than ="create_date"= / ="-create_date"=. The view (BE-7818)
translates this to 400.
- *Position is global rank.* Items' =position= reflects offset-aware rank, not
page-local index — so page 2 starts at position = offset, not 0.
- *Existing* =get_customer_favorite_pairings= in =app/services/api/pairing.py=
(used for =is_favorited= enrichment in pairing list responses) keeps working
unchanged because it reads the same truth tables.
- *Referral codes.* TBD given discussions between engineering and product.
Caching
Curated Cookbooks continue to use CDN caching from Exp 232. Per-request query
optimization (=.values()= on the through-rows; =.distinct()= on the product
filter to dedupe Tag M2M joins) is sufficient.
Django Settings
No new app registration. Adds one DC: =api.favorites.occasion_filters=
(see *Occasion Filters* above).
Cutover Plan
There is no data migration. Cutover is purely client-side.
1. Ship DC with mapped occasion bucket slugs to a list of tag_ids.
2. Mobile + web ship the new Favorites UI: fetch the occasion-filter mapping
at app launch, render bucket tabs, call the V3 favorites endpoint with the
appropriate =filter= bucket slug. Favoriting / unfavoriting continues to
call the existing V2 endpoints.
3. Ramp the experiment by cohort per Product. Compare engagement metrics
against the holdout running the legacy v2 read surface.
Risks & Mitigations
| Risk | Mitigation |
|----------------------------------------------------------------------------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Bad DC edit blanks out or corrupts the Lunch+Dinner bucket for all clients at once | Pydantic validator rejects unknown slugs at admin save. Getter returns ={}= on parse failure so clients see an empty bucket, not a crash. Admin audit log + 5-min =cache_memoize= TTL makes revert fast. |
| Client caches the occasion-filter mapping at launch and misses a mid-session DC edit | Acceptable for v1 — bucket reshuffles are infrequent and the next app open picks up the change. If staler behavior becomes a problem, the client can re-fetch on Favorites screen mount or honor a short TTL. |
| Custom user-defined ordering of favorites (drag-to-reorder) | Not supported in Phase 1. Underlying tables have no position column. If this becomes a requirement, either add a =position= column to the through-tables (additive, defaults to NULL) or revisit the truth-migration approach as a follow-up. |
| Mobile rendering branches on shape — Favorites returns a single synthetic =sections[0]= that is paginated, while curated Cookbook detail returns static =sections[*]=. | Both shapes share =sections[*].items[]=, so item iteration is identical. Clients branch on =cookbook_type == "favorites"= only to know the section is paginated. Every per-item field and almost every top-level field is identical, so all other render logic is shared. Documented in the API spec and the BE-7818 ticket. |
Data Analytics
Track:
- Favorites tab views (existing event), with new properties: =filter=
occasion bucket, =ordering=, page =offset=.
- Per-bucket impressions (client-side; the server can't tell "Lunch+Dinner"
from the slug list it received).
- Favorite / unfavorite events continue to be emitted by the existing V2
endpoints; no new BE events for writes in this experiment.
- (Phase 2) User-created cookbook create / edit / share events.
Segment
TBD with Data.
Back Office Considerations
Django Admin
No changes needed for Favorites in Phase 1. Existing admin for
=CustomerFavoritePairing= / =CustomerFavoriteProduct= in =app/admin/= remains
the back-office surface.
The =api.favorites.occasion_filters= DC is edited through the existing
=DynamicConfigAdmin= with a Pydantic validator attached.
For Phase 2 (user cookbooks): extend the existing =cookbooks= admin from Exp
232 to filter by =kind= and =owner=, and make user-owned cookbooks read-only
for staff (avoid editing customer-owned data).
Observability
Feature-level =structlog= events grouped under ="Favorites v3"=:
- =list_request= — emitted on GET =/cookbooks/favorites/=. Properties:
=customer_id=, =filter=, =count=, =total= (full favorites set
count, for power-user monitoring), =limit=, =offset=.
- Parse failure of =api.favorites.occasion_filters= logged at error level
by the getter (with the raw value snippet).
Favoriting / unfavoriting continues to be logged by the existing V2 endpoints.
No new write-side observability is needed.
Technical Debt
*Debt incurred*
- Two read surfaces (=/api/v2/.../favorite_pairings/= +
=/favorite_products/= for V2 reads, and
=/api/v3/.../cookbooks/favorites/= for the merged V3 read) coexist
indefinitely, both backed by the same tables. No skew is possible, but two
view files need maintenance. Cleanup ticket filed for post-cutover removal
of V2 reads once apps have moved.
- Writes remain split across two V2 endpoints; no consolidated V3 write
surface yet. Followup ticket if/when needed.
- Slug → tag-id map stored in DC is fragile. Should consider more robust tags
if this experiment wins.
*Debt avoided vs. the truth-migration alternative considered earlier*
- No lazy backfill, no advisory-lock dance, no bulk sweep command, no
dual-write code, no skew monitor, no =auto_now_add= corner case.
- No deprecation of =CustomerFavoritePairing= / =CustomerFavoriteProduct=
tables.
*Debt avoided vs. the UI-owned constant alternative considered earlier*
- No risk of iOS / Android / web drifting from each other when buckets are
reshuffled.
- No client release required to absorb a new =DishType= row into the
Lunch+Dinner bucket.
Open Questions
- *Initial bucket → slug mapping.* Default value for
=api.favorites.occasion_filters=. See draft in *Occasion Filters*; needs
design sign-off before launch.
- *Drag-to-reorder favorites.* Not in Phase 1 — underlying tables don't store
a position. If required, add a =position= column to the through-tables
(additive) and let the v3 endpoint honor an =ordering=position= query.
- *Sharing / discovery of user cookbooks.* Private by default? "Share to feed"
surface, with moderation, in scope?
Ticket Breakdown
Favorites Proxy (read-only MVP)
| Name | Estimate | Description |
|-------------------------------------------------------------------------------------------------------------------+----------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [[https://hungryroot.atlassian.net/browse/BE-7817][BE-7817]] - =FavoritesService.list_favorites= | 1 | Read through-rows from =CustomerFavoritePairing= / =CustomerFavoriteProduct=. Synthesize Cookbook-shaped envelope (every =CookbookSerializer= top-level field with null/empty defaults; =cookbook_type="favorites"= via new enum value). Pairings: =dish_type__slug__in=. Products: hybrid =Q(dish_types__slug__in) \| Q(tags__id__in)= so RTE meals (empty M2M) show under Lunch+Dinner; slug → daypart-tag-id map lives in =cookbooks/const.py=. Tests for empty / mixed / filtered / RTE coverage / dedup / count semantics. |
| [[https://hungryroot.atlassian.net/browse/BE-7818][BE-7818]] - Favorites v3 GET endpoint | 1 | =GET /api/v3/customers/{id}/cookbooks/favorites/= (read-only). Serializer, viewset, URL wiring. Translate =ValueError= from the service (invalid =ordering=) to 400. Emit =Favorites v3 / list_request= structlog with =total= kwarg for power-user monitoring. Tests for happy path, occasion filter, pagination, permission checks. *Writes (POST/DELETE) are out of scope* — clients use the existing V2 favorite endpoints. |
| TODO ticket - New CookbookType =favorites= and auto-creation when user "shares" their cookbook | 1 | TBD: Need to figure out when, how or why a user would ask to share cookbook. Is it on referral? Creating new =favorites= cookbook for a user should auto-generate a slug based on the Base62-encoded random bytes technique. |
| *Total* | 4 | |
Appendix
Glossary
- *Pairing* :: A recipe in the Hungryroot catalog (=app.Pairing=).
- *Product* :: A SKU in the Hungryroot catalog (=app.Product=).
- *DishType* :: A categorical tag on Pairings (=app.DishType=); 21 rows
including =salad=, =pasta=, =pizza=, =stir-fry=, =grain-bowl=, etc.
Identified by stable =slug=.
- *Daypart dish-type Tag* :: A Tag in =TagGroup.LUNCH_DISH_TYPE= (41) or
=TagGroup.DINNER_DISH_TYPE= (39), e.g. =Tag.ID.LUNCH_SALAD=,
=Tag.ID.DINNER_PASTA=. Used to categorize RTE / heat-and-eat products that
don't carry =Product.dish_types=.
- *Curated Cookbook* :: Staff-authored Cookbook from Exp 232.
=cookbook_type='thematic|personality'=. =CookbookAuthor.user= is null.
- *Favorites (proxy)* :: A customer's favorited pairings + products, rendered
as a synthesized Cookbook by the v3 read endpoint. Not backed by =Cookbook=
rows. =cookbook_type="favorites"=.
- *User Cookbook* :: Customer-authored Cookbook. =cookbook_type='favorites'=,
=owner=CookbookAuthor.user.customer=.
Inline Review Comments
Open
- =Extend Cookbook with owner (FK to Customer, nullable)= :: Wally Chang —
"if a cookbook has both a owner and author, what will this logic look
like?"
- Reply (Colin): author should be limited to curated cookbook types and
not be linked to a customer or user. Owner is the FK that ties a
cookbook to a particular customer.
- =Favoriting/unfavoriting becomes create/delete of a CookbookItem in that
customer's Favorites cookbook.= :: Wally Chang — "This requires a client
change and we'd need to keep bookmark in sync with cookbook items. Have
you thought more about using cookbook as a proxy instead of migrating all
the data to cookbook?"
- Reply (Wally): "i think a dedicated section on how we plan to handle the
dual-write would be helpful here"
- Reply (Colin): "damn, I forgot about the proxy idea … let me come up
with a summary of what that would look like" — addressed by current
proxy design.
- =MealCategory (NEW, lookup)= :: Wally Chang — "What is this used for?"
- Reply (Colin): addresses first bullet of Wally's Slack message
[[https://hungryroot.slack.com/archives/C0B4QA7Q05A/p1779202055262089][here]].
- =breakfast, lunch_dinner, snacks_and_more= :: James Chen — avoid tying API
params to UI tab names; use stable domain-level enum; let UI map labels to
that. Follow-up: "are we adding new dish types =breakfast=,
=lunch_dinner=, =snacks_and_more= specifically for this filter?"
- Reply (Colin): "no, specific enums for this that would map to existing
dish types … I'll add a note that we should use existing dish_types and
let the UI map as needed." — addressed by current dish_type slug param.
- =-created_at= :: James Chen — "Does this mean that the endpoint will
support both =-= and without =-=?"
- Reply (Colin): yes.
- =Team prefers shipping the Favorites surface fast and revisiting
truth-migration as a follow-up= :: Colin — "I think this is exactly where
we are @James Chen … I'm gonna update this doc to use the pass-through
model strategy rather than adding fields to the cookbook model"
- =UI defines (in app code) a small constant per platform= :: Colin — "@Wally
Chang not sure if this is what you had in mind with regards to UI layer
defining those constants. Let me know!"
- Reply (James): "I was mostly thinking about naming the params to
something more generic/stable. I also think having the client own these
buckets could become problematic. If we want to add/remove/reorganize
dish types in the future, we'd need coordinated app updates across
platforms instead of being able to adjust the grouping server-side."
- Reply (Wally): "Can this be a DC and we can abstract out to models if
this experiment wins? label → query filters (i want to reduce as much
scope as possible)…"
- *RESOLVED:* DC-backed mapping shipped (BE-7819). See *Occasion Filters*
above.
- =Request Body:= (Favorite an Item) :: James Chen — "is =item_type=
necessary if the request is already distinguished by =pairing_id= vs
=product_id=? If we do have both, what happens if the values are
mismatched?"
- Reply (Andrew): "Is there a reason clients can't continue using the
same =POST /api/v2/customers/{customer_id}/favorite_pairings/= and the
product version pattern? Clients need the synthesized item dict from
the GET response?"
- Reply (Wally): "FWIW, migrating over to the V3 endpoint isn't high on
my priority list for the MVP if the V2 endpoint works for the
experiment."
- Reply (James): "@Andrew Kim clients can continue to use v2. For the
MVP, we're also going to stick with V2 and migrate to V3 at a later
point."
- *RESOLVED:* V3 surface is read-only for MVP; writes stay on V2.
- =Position= :: Wally — "I am more in favor of not using =position= and
instead, use the order returned from the BE based on any sort/filter
query parameters used by clients."
- =Not stable across requests= :: James Chen — "Is it stable across pages?"
- Reply (Wally): "cc @Colin Powell if we provide a default sort, then this
should be stable right?"
- *RESOLVED:* Position is now the offset-aware global rank in the sorted
result set. Stable across pages for a given sort.
- =Phase 2= :: Wally — "AFAIK, creation of a cookbook and sharing (publically)
the user's cookbook is part of phase 1."
- Reply (Colin): originally proxy MVP scope. If sharing cookbooks is
Phase 1 too, prefer to actually modify the Cookbook model rather than
proxy. @James Chen — what do you think?
- Reply (Wally): proxy is to avoid lengthy migration + dual write while
experiment runs. Once experiment done and rolling out to everyone,
original (model-mod) approach is better. Maybe warrants live call.
- Reply (Wally, separate): can we add a different "personal" type to the
existing cookbook infrastructure for user-generated cookbooks?
- =/cookbooks/favorites/= :: Wally — "For the future: Does this return all
favorites from all cookbooks or does it accept some sort of cookbook ID?"
- =Overview= :: Wally — "Is there documentation around supporting publicly
accessible user-generated cookbooks?"
- =Meal-category filtering is UI-driven. The new endpoint accepts a dish_type
query parameter (CSV of DishType slugs). Clients map their bucket labels
("Breakfast", "Lunch + Dinner", "Snacks & More") to slug lists. No
server-side mapping table.= :: Wally — "if a dish_type param is provided,
would =breakfast= filter show products like yogurt, pancake?"
- *RESOLVED (partial):* For Lunch+Dinner, server-side mapping (DC) +
hybrid Tag filter handles ingredient and RTE products. Breakfast/Snacks
remain open — see *Coverage gap* and Open Questions.
- =favorites_v3_enabled= :: Wally — "hmm what is this used for?"
- Reply (James): "Is this meant for the clients? Unless the BE needs this,
we typically create our dcs under the prefix =ios.= and =android.=. We
also don't always have feature gates for v3 migration."
- *RESOLVED:* DC dropped. Cutover is purely client-side via app release;
no server-side gate needed.
- =Response:= (Retrieve Personal Favorites Cookbook) :: Wally — "is this the
same shape as other cookbook APIs? if so, we can probably leave it the
same, if not, we could optionally use v3 pairings/products endpoint for
hydration to keep the payload small."
- Reply (Wally): "+1 I expect this to only return information specific
to cookbooks and omit anything we can get from hydrating on the
client."
- *RESOLVED:* Response now mirrors =CookbookSerializer= top-level fields.
Item payload is minimal (=id=, =item_type=, =position=, =pairing_id=,
=product_id=) — clients hydrate via v3 pairing/product endpoints.
- =Favorite an Item= :: Wally — "does it make sense to continue using the
bookmark endpoints for favoriting and unfavoriting to decrease scope?"
- *RESOLVED:* Yes — V2 endpoints stay for writes in MVP.
- =fields= (Phase 2 model) :: Andrew Kim — "How is =owner= to be used
differently than =author=?"
- Reply (Colin): author was how a cookbook got associated with an HR
customer. But for sharing cookbooks, customers probably don't need a
whole "author" profile, so add an owner FK to customer and deprecate
the Author.user.
- =no data migration required= :: Andrew Kim — "You're probably not going to
be able to add a non-nullable field here because it's not
backward-compatible. You'll have to make a nullable field and existing
cookbooks won't be backfilled automatically with =CURATED=."
- =Products are unaffected by this filter= :: Andrew Kim — "What about
ready-to-eat/heat&eat meals, which are products? These have dish types
via tags. On that note, should we be filtering based on dish type tags
instead of the dishtype FK?"
- *RESOLVED:* Hybrid filter shipped (BE-7817 scope expansion). Product
filter ORs =Product.dish_types__slug__in= with daypart Tag membership
(=Tag.ID.LUNCH_*= / =Tag.ID.DINNER_*=) so RTE meals surface under
Lunch+Dinner. See *Services* and *Occasion Filters* above.
- =Response:= (Retrieve Personal Favorites Cookbook, second comment) ::
Andrew Kim — "I don't think it's v3-esque to include the item details.
Clients should be passing the pairing/product IDs to the v3
pairing/product cached endpoints to fetch whatever pairing/product
details they need. Also, do we need =position= or =created_at=? Also,
not sure about the composite pairing/product IDs pattern. Is that
better/easier for clients to delete a favorite rather than passing
something like =pairing_id=x= or =product_id=y=?"
- *RESOLVED (partial):* Item payload trimmed to minimal IDs; no nested
pairing/product details. =position= retained as offset-aware global
rank. =created_at= dropped from the item; ordering happens on the
through-row's =create_date= server-side. Composite IDs kept since
there's no real PK for the synthesized item — through-row PKs overlap
across tables.
- =Unfavorite an Item= :: Andrew Kim — "Same question as above for
favoriting an item. Clients could keep using =DELETE
/api/v2/customers/{customer_id}/favorite_pairings/{pairing_id}/= and
product one?"
- *RESOLVED:* Yes — V2 endpoints stay for writes in MVP.