Org Web Adapter

hungryroot/popsicle/exp-242-favorites.org

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.