Org Web Adapter

hungryroot/brand/exp242-favorites-tab-backend-review.org

ID
2ed3da6a-7f21-4027-b51f-7736b245b64f

Favorites Tab β€” Backend API Review

TL;DR

Today's APIs cannot support a paginated, mixed product+recipe Favorites

tab with occasion filters and "sort by recent" without new backend work.

- *All tab (mixed list):* No unified endpoint. v2 favorites endpoints

return ID-only lists with no pagination or sort.

- *Occasion filters:* Breakfast works differently for products vs

recipes. "Lunch + Dinner" and "Snacks + More" are client labels β€” not

backend enums. Products have no =is_lunch=/=is_dinner= flags.

- *Sort by recent:* Existing =is_recent= means "in recent orders", not

"recently favorited". Join tables have =create_date= but it is not

exposed in any API.

- *Pairing text:* All CMS content (=featured_quote=,

=short_instruction_html=). Favorites store no user text.

- *Recommendation:* New =GET /api/v3/customers/{customer_id}/favorites/=

with pagination, =occasion= filter, and =ordering=-favorited_at=.

Executive Summary

The proposed mobile Favorites tab requires capabilities that do not exist

today. A new unified v3 endpoint is the right approach. Keep existing v2

POST/DELETE favorites endpoints for write operations.

One terminology fix: *Breakfast is not a =dish_type= in the backend.*

=DishType= means recipe format (Stir-Fry, Burger, Tacos). Meal-occasion

filtering uses different mechanisms on products vs pairings.

Current API Landscape

Dedicated v2 favorites endpoints (ID lists only)

| Method | Path | Returns |

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

| GET/POST | =/api/v2/customers/{id}/favorite_pairings/= | =[{"pairing_id": N}]= |

| DELETE | =/api/v2/customers/{id}/favorite_pairings/{pairing_id}/= | 204 |

| GET/POST | =/api/v2/customers/{id}/favorite_products/= | =[{"product_id": N}]= |

| DELETE | =/api/v2/customers/{id}/favorite_products/{product_id}/= | 204 |

*Files:*

- =app/rest/customer_favorites.py=

- =app/rest/customer_favorite_products.py=

- =app/models/customer_favorite_pairing.py=

- =app/models/customer_favorite_product.py=

*Limitations:*

- =limit= query param only β€” no =offset=, =count=, or =next=

- No sort order (OpenAPI: "does NOT have caching or a custom sort order")

- No occasion filtering

- No card/detail payload β€” clients must hydrate separately

v2 list endpoints (full objects, separate lists)

| Endpoint | Pagination | Favorite filter |

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

| =GET /api/v2/products/?customer_id=X&is_favorite=true= | limit/offset | yes |

| =GET /api/v2/pairings/?customer_id=X&is_favorite=true= | limit/offset | yes |

These work for *separate* product or pairing feeds but cannot produce one

interleaved "All" tab with a single sort order.

v3 endpoints (partial coverage)

| Endpoint | Favorites support |

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

| =GET /api/v3/customers/{id}/pairings/?is_favorite=true= | Lightweight IDs + liked/is_merchandized |

| =GET /api/v3/pairings/?ids=...= | Full static pairing data (no favorite flag) |

| =GET /api/v3/products/?ids=...= | Full static product data (no =is_favorite= filter) |

No v3 CRUD for favorites. No v3 customer favorite products list.

Answers to Open Questions

1. Under "All", are we mixing products and recipes?

*Yes β€” intended UX, but no API supports it today.*

Workaround (not recommended for production):

1. Fetch both v2 favorite ID lists

2. Merge client-side

3. Hydrate via =/api/v3/products/?ids=...= and =/api/v3/pairings/?ids=...=

This breaks at scale: no server-side pagination, no stable interleaved

sort, minimum two round-trips.

**Recommended response shape for new endpoint:*

#+begin_src json

{

"count": 42,

"next": "...",

"results": [

{"item_type": "pairing", "id": 463891, "favorited_at": "2026-05-18T..."},

{"item_type": "product", "id": 1210, "favorited_at": "2026-05-17T..."}

]

}

#+end_src

Clients hydrate card data via existing v3 bulk endpoints, or the new

endpoint can optionally =expand=card= to embed card fields server-side.

2. "Breakfast is a dish_type today" β€” will that work?

*No β€” not as stated.* This is a common confusion.

| Concept | Products | Pairings (recipes) |

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

| Dish type (Stir-Fry, Burger…) | M2M =dish_types= β€” suggested use, not meal occasion | FK =dish_type= β€” recipe format |

| Meal occasion (Breakfast, Lunch…) | Boolean flags: =is_breakfast=, =is_snack=, =is_sweet=, =is_fruit=, =is_kid_snack= | Occasion tags: Breakfast-Friendly (47), Lunch-Friendly (48), Dinner Recipe (173) |

If mobile labels a filter "Breakfast" but passes =dish_types=..., that is

likely incorrect for recipes unless using a different screen's filter

model.

Occasion tab β†’ API mapping

| Tab | Pairings filter | Products filter |

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

| All | (none) | (none) |

| Breakfast | =occasion_tags=47= | =is_breakfast=true= |

| Lunch + Dinner | =occasion_tags=48,173= | *No native flag* β€” see open question |

| Snacks + More | No exact backend enum | =is_snack= OR =is_sweet= OR =is_fruit= OR =is_kid_snack= |

Backend strings "Lunch + Dinner" and "Snacks + More" do *not* exist in

this repo. Closest concepts:

- =OccasionGroupIDs.MEALS= (pk=1) β€” =plan_service/const.py=

- =OccasionGroupIDs.THROUGHOUT_THE_DAY= (pk=2) β€” Breakfast, Snacks,

Sweets, Fruit in =plan_service/fixtures/occasions.json=

*Recommendation:* Define a single backend param on the new endpoint,

e.g. =occasion=breakfast|meals|snacks_and_more=, and let the server map

to the correct product booleans + pairing occasion tags. Do not expose

raw =dish_types= for meal-occasion tabs.

Open question: "Lunch + Dinner" for products

Products have no =is_lunch=/=is_dinner= flags. Options:

1. *Inverse heuristic:* meal product = NOT (=is_breakfast= OR =is_snack=

OR =is_sweet= OR =is_fruit= OR =is_kid_snack=) β€” simple but imprecise

2. *Occasion tags on products* via existing =occasion_tags= filter in

=app/rest/product.py= β€” only works if products are tagged consistently

3. *Recipes-only filter:* Lunch+Dinner tab shows pairings only; products

appear in All/Breakfast/Snacks β€” simplest but inconsistent UX

Align with mobile/design before implementing.

3. No unified endpoint β€” pagination requirement

*Confirmed gap.*

Existing v2 favorites GET only supports =limit= truncation:

#+begin_src python

favorite_pairings = obj.favorite_pairings.values(pairing_id=F("pk"))

limit = self.context["request"].query_params.get("limit")

if limit and limit.isdigit():

favorite_pairings = favorite_pairings[: int(limit)]

#+end_src

File: =app/rest/customer_favorites.py=

Both through models already have =create_date= (migration

=app/migrations/0697_customerfavoriteproduct_customerfavoritepairing_and_more.py=)

which can serve as =favorited_at=.

Discovery collections use =item_type= discrimination

(=discovery_home/const.py= =CollectionItemType=) but never mix types in

one collection β€” favorites would be the first truly mixed-type paginated

feed.

4. Product card mockup β€” what fields exist?

There is no =ProductCardSerializer=. Closest references:

Option A β€” Discovery home card (richest, browse carousels)

=CollectionProductSerializer= in =discovery_home/views/v2/collection.py=

- Image: =card_image_url=

- Text: =name=, =description=, =brand_name=

- Metadata: =badges=, =points=, =points_after_discount=, =category_name=,

=spicy_level=, =is_new=

- Occasion hints: =is_breakfast=, =is_snack=, =is_sweet=, =is_fruit=

Option B β€” Lighter list card

=ProductDetailListSerializer= in =app/rest/product.py=

- =card_image_url=, =name=, =description=, =brand_name=, =points=,

=rating=, occasion booleans

Option C β€” v3 static bulk

=ProductSerializer= in =app/rest/v3/product.py=

- =card_image_url=, =name=, =points=, =brand_name=, =rating=

Suggested product card fields for design mockups

| UI element | API field |

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

| Hero image | =card_image_url= |

| Title | =name= |

| Subtitle | =brand_name= or =description= (truncate) |

| Badges | =badges[]= (personalized) |

| Points | =points= / =points_after_discount= |

| Type pill | =item_type: "product"= (from unified endpoint) |

Recommend matching =CollectionProductSerializer= if Favorites should look

like Discover/browse cards. Cards need a visual *type indicator* (product

vs recipe) since pairings use =featured_img_url= + =featured_quote=

while products use =card_image_url= + =description=.

Pairing card baseline: =PairingListSerializer= (=app/rest/pairing.py=) or

=CollectionPairingSerializer= (=discovery_home/views/v2/collection.py=).

5. "Sort by recent" β€” new param needed

*Correct β€” and clarify semantics first.*

Existing =is_recent= on products/pairings means *"in customer's recent

orders"*, not "recently favorited":

#+begin_src python

recent_order_ids = list(

CustomerOrderService(self._customer).get_recent_orders().values_list("pk", flat=True)

)

return queryset.filter(orders__in=recent_order_ids).distinct()

#+end_src

File: =app/rest/v3/pairing.py=

Neither =/api/v2/.../favorite_*= nor =/api/v3/customers/.../pairings/=

exposes sort-by-favorited-date.

*Good news:* Join tables have =create_date= but it is not exposed in any

API today.

For "sort by recently favorited":

- Add =ordering=-favorited_at= on the new unified endpoint

- Expose =favorited_at= (from through-model =create_date=) in response

- Do *not* reuse =is_recent= β€” different meaning

If product also wants "recently ordered favorites," that could be a

separate =ordering=-last_ordered_at= computed from order history β€”

significantly more expensive.

6. Text on favorited pairings β€” user generated?

*No. All system/CMS content.*

Favorite record stores only =(customer, pairing)= β€” no text field:

#+begin_src python

class CustomerFavoritePairing(BaseModelV1):

customer = models.ForeignKey(Customer, on_delete=models.CASCADE)

pairing = models.ForeignKey(Pairing, on_delete=models.CASCADE)

#+end_src

File: =app/models/customer_favorite_pairing.py=

Text on pairing cards comes from the *Pairing* model:

- =featured_quote= β€” CMS quote + attribution ("quote | attribution")

- =short_instruction_html= β€” CMS cooking instructions

- =name=, =nutrition_info= β€” also system content

Same for products: =name=, =description= are CMS fields. Users can

favorite/unfavorite but cannot attach notes.

Proposed Architecture

#+begin_example

Current (insufficient):

v2 favorite_products β†’ IDs only, no sort

v2 favorite_pairings β†’ IDs only, no sort

v2 products?is_favorite β†’ paginated, products only

v2 pairings?is_favorite β†’ paginated, pairings only

Proposed:

GET v3/customers/{id}/favorites/ β†’ paginated union

GET v3/products?ids=... β†’ hydrate product cards

GET v3/pairings?ids=... β†’ hydrate pairing cards

v2 POST/DELETE favorites β†’ unchanged (writes)

#+end_example

Interim Workaround (No Backend Changes)

Works for separate sections or type-specific tabs, *not* a unified sorted

"All" feed.

| Tab | Products | Pairings |

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

| All | =GET /api/v2/products/?customer_id=X&is_favorite=true&limit=20&offset=0= | =GET /api/v2/pairings/?customer_id=X&is_favorite=true&limit=20&offset=0= |

| Breakfast | add =is_breakfast=true= | add =occasion_tags=47= |

| Lunch+Dinner | *no clean filter* | add =occasion_tags=48,173= |

| Snacks+More | filter per-flag client-side or multiple calls | *no clean filter* |

Recommended Backend Scope

New endpoint

=GET /api/v3/customers/{customer_id}/favorites/=

Query params:

- =occasion=all|breakfast|meals|snacks_and_more=

- =ordering=-favorited_at|favorited_at|name=

- =limit=20&offset=0=

- =origin=...&inventory_date=...= (optional, for =is_merchandized=)

Response: ={item_type, id, favorited_at}= + optional expanded card payload

Implementation sketch

1. UNION query over =customer_favorite_products= and

=customer_favorite_pairings= through tables

2. Use through-model =create_date= as =favorited_at=

3. Apply occasion filter per item type before union

4. Paginate on unified queryset

5. Keep v2 POST/DELETE favorites endpoints unchanged

Tests

- Pagination (limit/offset, count, next)

- Occasion filter per item type

- Sort order (=-favorited_at= default recommended)

- Empty states

- Auth (=IsCustomerPathAuthorized=)

No migration needed

Timestamps already exist on through tables (migration 0697).

Out of scope (unless requested)

- v3 favorite products personalized list (mirror of v3 pairings)

- User-generated favorite notes

- Changing v2 write endpoints

Risks and Callouts

- *Occasion filter parity* between products and pairings is asymmetric β€”

needs product/design sign-off on "Lunch + Dinner" product logic

- *Re-favoriting* after unfavorite: =create_date= reflects first

favorite, not re-favorite (unless through row is updated on re-add β€”

verify desired behavior)

- *Default sort* when no =ordering= param: recommend =-favorited_at= for

Favorites tab UX

- *Performance:* UNION + occasion joins should use existing indexes on

through tables; evaluate EXPLAIN if customer favorite counts are large

- *Do not confuse =is_recent= with recently favorited=* β€” document in

OpenAPI

Open Decisions

- [ ] Confirm "Sort by recent" means =favorited_at= (not =is_recent=/order

history)

- [ ] Align occasion tab mapping with mobile/design β€” especially

Lunch+Dinner for products and Snacks+More for pairings

- [ ] Decide default sort when no =ordering= param

- [ ] Decide re-favorite behavior for =favorited_at= timestamp

Key File References

| Topic | Files |

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

| v2 favorites APIs | =app/rest/customer_favorites.py=, =app/rest/customer_favorite_products.py= |

| Through models | =app/models/customer_favorite_pairing.py=, =app/models/customer_favorite_product.py= |

| v2 product list + filters | =app/rest/product.py= |

| v2 pairing list + filters | =app/rest/pairing.py= |

| v3 customer pairings | =app/rest/v3/pairing.py= |

| v3 products | =app/rest/v3/product.py= |

| Product card serializers | =discovery_home/views/v2/collection.py=, =app/rest/product.py= |

| Occasion constants | =app/const.py= (=Occasion=), =plan_service/const.py= (=OccasionGroupIDs=) |

| Occasion tags | =app/models/tag.py= (IDs 47, 48, 173), =app/fixtures/Tag.json= |

| DishType (recipe format) | =app/models/dish_type.py=, =app/fixtures/DishType.json= |