- ID
- c3c5da01-766c-4975-b6a9-d739c426b146
Favorites Tab as Step 1 Toward User-Created Shareable Cookbooks
TL;DR
Reframes the Favorites tab work
(exp242-favorites-tab-backend-review.org,
exp242-cookbook-reuse-analysis.org)
in light of a longer-term destination: *user-created, shareable
cookbooks*. The Favorites tab is the first user-facing surface that
should be shaped by cookbook conventions, even though it does not
require any cookbook-app changes to ship.
Locked direction:
- *This project (favorites tab):* build the new
=GET /api/v3/customers/{customer_id}/favorites/= endpoint backed by
the existing =CustomerFavoritePairing= / =CustomerFavoriteProduct=
tables. Response contract is cookbook-shaped.
- *Follow-up project (cookbook ownership):* extend the =cookbooks= app
with =owner_user=, =is_public=, a =PERSONAL= cookbook type,
=IsCookbookOwnerOrStaff= permission, customer-facing write endpoints,
and an opaque =share_code= replacing =slug= as the canonical identity.
- *No data unification.* Favorites stay in =CustomerFavorite*= tables.
No auto-bootstrapped personal cookbook per customer in step 1.
Why Reframe Now
The prior analysis concluded (correctly, in isolation) that the
=cookbooks= app was the wrong host for favorites storage. That
conclusion still holds for *storage*. What changes when shareable
cookbooks become a real destination is that the *contract* shipped by
the favorites endpoint should already speak the cookbook dialect so the
follow-up project is purely additive.
The cookbook app today is a closed editorial CMS:
| Surface | Today |
|---------+-------|
| Reads | =AllowAny=, slug-based detail URLs |
| Writes | =IsStaffOrParfaitClient= only |
| Ownership | =CookbookAuthor.user= FK exists but never read |
| Visibility | =CookbookAuthor.is_public= only; nothing on =Cookbook= |
| Identity | Required, unique =SlugField= derived from name |
| Mixed items | =CookbookItem= with =item_type= enum + nullable typed FKs |
Approximately 60% of the scaffolding for user-created cookbooks is in
place. The follow-up project closes the gap. The favorites tab is step
one because it ships the *client vocabulary* (item-type discrimination,
typed-id rows) that personal cookbooks will speak natively.
This Project — Favorites Tab
Endpoint
=GET /api/v3/customers/{customer_id}/favorites/=
Query params: =occasion=, =ordering=, =limit=, =offset= per
exp242-favorites-tab-backend-review.org.
Permissions: =[IsStaff | IsCustomerPathAuthorized]= — mirror
=CustomerPairingsView= in =app/rest/v3/pairing.py:710=.
Response shape — cookbook dialect
#+begin_src json
{
"count": 42,
"next": "...",
"results": [
{"item_type": "pairing", "pairing_id": 463891, "product_id": null, "favorited_at": "2026-05-18T..."},
{"item_type": "product", "pairing_id": null, "product_id": 1210, "favorited_at": "2026-05-17T..."}
]
}
#+end_src
Field choices that pay off later:
- =item_type= values reuse =CookbookItemType= choices
(=cookbooks/models.py= L17-19). Import directly; do not redeclare.
- Typed =pairing_id= / =product_id= (exactly one populated) matches
=CookbookItemSerializer= and the =CookbookItem= DB check constraint
(=cookbookitem_one_target_ck=).
- Hydration stays on existing =GET /api/v3/products/?ids=...= and
=GET /api/v3/pairings/?ids=...=. Cards on a future personal-cookbook
detail screen reuse the same hydration calls.
Storage and service boundary
- Storage: unchanged. =CustomerFavoritePairing= /
=CustomerFavoriteProduct= remain canonical writes. =create_date=
(migration 0697) serves as =favorited_at=.
- Service: new =CustomerFavoritesService.list_favorites= in
=app/services/customer_favorites_service.py=. Builds two =values()=
projections with a synthetic =item_type= column, unions, orders,
returns. View delegates.
- The service boundary is the future migration seam: if/when favorites
ever move to =CookbookItem= rows, the public contract is unchanged.
Write endpoints unchanged
v2 POST/DELETE on =/api/v2/customers/{id}/favorite_pairings/= and
=/.../favorite_products/= stay canonical. No write-side client churn.
Follow-up Project — User-Created Shareable Cookbooks
Out of scope for the favorites tab project. Captured here so the
follow-up ticket starts with a clear shape.
Ownership
Add =Cookbook.owner_user = FK(settings.AUTH_USER_MODEL, null=True,
on_delete=SET_NULL)=. Editorial cookbooks: =owner_user=NULL=. Personal
cookbooks: =owner_user=<user>=.
Mirrors the existing =CookbookAuthor.user= pattern. Indirection to
Customer is via =user.customer= where needed. Nullable so editorial
content can keep existing semantics with zero data backfill.
Visibility
Add =Cookbook.is_public = BooleanField(default=False)=. Read queries
filter to =is_public=True OR owner_user == request.user=. Editorial
cookbooks set =is_public=True= on activation (existing =is_active=
flag remains the editorial enable gate).
Type
Extend =CookbookType= choices with =PERSONAL = "personal"=. Personal
cookbooks bypass editorial fields (=is_featured=, =sort_order=, =author=
metadata) in admin listings.
Permission
New =IsCookbookOwnerOrStaff= in =cookbooks/= or =app/rest/permissions/=.
Allows staff + parfait (existing) + authenticated user whose
=request.user.pk == cookbook.owner_user_id=.
Customer-facing write endpoint: =POST /api/v3/cookbooks/= and
=PUT /api/v3/cookbooks/{share_code}/=. Service-layer enforces
=owner_user= assignment on create and ownership check on update.
Identity — share code, not slug
The biggest contract change in the follow-up. Today =Cookbook.slug= is
required, unique, and auto-derived from =name= in =Cookbook.clean()=
(=cookbooks/models.py= L80-81). All v3 detail routes use slug as the
path arg.
Constraints this creates for user-created cookbooks:
- Two customers with the same cookbook name collide on slug
- Slugs leak titles; bad for "share with a friend" links
- Mutable identity (rename → new slug) breaks shared links
Plan for the follow-up:
- Add =Cookbook.share_code = CharField(max_length=12, unique=True,
db_index=True)=
- Auto-populate on first save via =secrets.token_urlsafe(8)= (stdlib,
~64 bits, ~11 URL-safe chars). Collision-check + retry. No new
dependency.
- New canonical route: =/api/v3/cookbooks/{share_code}/=
- =slug= becomes optional + non-unique; retain only as a human-readable
SEO handle on editorial cookbooks. Drop =cookbook_uniq_slug=
constraint.
- Backfill =share_code= for existing editorial cookbooks in the same
migration. Keep slug routes working as 301-style redirects to
share-code URLs until clients update.
If a curated alphabet (no =-=, =_=) is wanted later, swap
=token_urlsafe= for =nanoid= or hashids — same interface, no client
impact.
Out of Scope (this project)
- =Cookbook.owner_user=, =is_public=, =PERSONAL= type
- =IsCookbookOwnerOrStaff= permission, customer write endpoints
- =Cookbook.share_code= and slug deprecation
- Migration of =CustomerFavorite*= rows into =CookbookItem=
- "Save my favorites to a new cookbook" UX
- =Lunch+Dinner= filter for products (open question from prior doc)
Sequence
1. *Now:* favorites tab v3 endpoint (this project). Mobile ships
Favorites tab.
2. *Next:* cookbook ownership + share-code groundwork. No customer-facing
UX yet; lays the API foundation.
3. *Later:* customer UX — "create a cookbook from your favorites",
"share this cookbook" link, personal cookbook detail screen.
4. *Maybe:* discover/browse public cookbooks; collaborator invitations;
etc. Not committed.
Cross-References
- exp242-favorites-tab-backend-review.org —
API gap analysis and endpoint design
- exp242-cookbook-reuse-analysis.org —
why cookbook storage isn't reused; what *is* reused (the dialect)
- Plan file: =~/.claude/plans/what-if-we-wanted-temporal-starfish.md= —
working scratchpad for this project's implementation plan