Org Web Adapter

hungryroot/brand/exp242-favorites-as-step-1.org

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