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